Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce666c768e | ||
|
|
56dd3187f1 | ||
|
|
42f1f55fd3 | ||
|
|
36e03258d8 | ||
|
|
d91f1ec277 | ||
|
|
ec4283e37f | ||
|
|
35b126449b | ||
|
|
5d15b753f7 | ||
|
|
d800af049b | ||
|
|
5852e14a5c | ||
|
|
d92cfcbd8c | ||
|
|
66eb5312c5 | ||
|
|
7031352e85 | ||
|
|
690e76a51f | ||
|
|
eb229051ef | ||
|
|
28325af448 | ||
|
|
e3dd1526e0 | ||
|
|
b1bf68022d | ||
|
|
2459a759af | ||
|
|
c9305fd0e6 | ||
|
|
e44c929afc | ||
|
|
88ce89428b | ||
|
|
69fb510c50 | ||
|
|
59362fcecf | ||
|
|
df3fe660e5 | ||
|
|
00663a870b | ||
|
|
b321556dbe | ||
|
|
3846c277c8 | ||
|
|
a9baebc396 | ||
|
|
5db234bdf3 | ||
|
|
f78c8b6959 | ||
|
|
45214d3ca8 | ||
|
|
cd8750462f | ||
|
|
97d4bd9061 | ||
|
|
b4e55b47c1 | ||
|
|
8ea427de3c | ||
|
|
0d9910efc6 | ||
|
|
21500b45be | ||
|
|
13d003b89d | ||
|
|
d64a61d677 | ||
|
|
f4c21c4e2c | ||
|
|
49be1fe354 | ||
|
|
62188ec740 | ||
|
|
2147eaa6a5 | ||
|
|
b1e56877aa | ||
|
|
939f33a3a3 | ||
|
|
c963dafd09 | ||
|
|
1526c957cf | ||
|
|
7f60a2547c | ||
|
|
60d5504fb4 | ||
|
|
7407a82aa7 | ||
|
|
743fefe7f1 | ||
|
|
735683b849 | ||
|
|
98e0ad0537 | ||
|
|
125fccbb46 | ||
|
|
7d1e6f724e | ||
|
|
37d8744db8 | ||
|
|
c36d4c80c8 | ||
|
|
5945f7420a | ||
|
|
610f679881 | ||
|
|
5bcd0e0269 | ||
|
|
ae32ed7974 | ||
|
|
96a7b405c2 | ||
|
|
c75863c93f | ||
|
|
c396a22dfd | ||
|
|
c0e1179936 | ||
|
|
586698db00 | ||
|
|
9a7b045df4 | ||
|
|
1d7106c94d | ||
|
|
924a837389 | ||
|
|
7421837c8a | ||
|
|
acd694c2cf | ||
|
|
85dafd78b7 | ||
|
|
a881fa3693 | ||
|
|
e7dbdeb21f | ||
|
|
37f63edbf9 | ||
|
|
6163b63895 | ||
|
|
ec713585a5 | ||
|
|
ee10097435 | ||
|
|
b1433ad4a9 | ||
|
|
12d320cf33 | ||
|
|
9b4f0815e5 | ||
|
|
5ecb35864f | ||
|
|
94cfea4748 | ||
|
|
c6a846a1f9 | ||
|
|
0da8271836 | ||
|
|
ecd6df686c | ||
|
|
caf61d41a5 | ||
|
|
3085ec4d2e | ||
|
|
2fba685e16 | ||
|
|
6ae0af3f72 | ||
|
|
c72f197880 | ||
|
|
d2c909414c | ||
|
|
12a457b8d0 | ||
|
|
90d6e551b3 | ||
|
|
1980fda737 | ||
|
|
80f1a210cc | ||
|
|
deabac0923 | ||
|
|
4d8196174c | ||
|
|
892da2662a | ||
|
|
76a751f125 | ||
|
|
3cfa78cde2 | ||
|
|
ca833b6d2b | ||
|
|
f76e024f41 | ||
|
|
f1cd6ed5b3 | ||
|
|
6acc73a9fa | ||
|
|
edcaf42ded | ||
|
|
d2d5d6f22b | ||
|
|
02d629922f | ||
|
|
b0206cbc7a | ||
|
|
5700c37016 | ||
|
|
18f20170b3 | ||
|
|
6621c11ad6 | ||
|
|
71554714e7 | ||
|
|
09f6cf8c0f | ||
|
|
6a7ed37aed | ||
|
|
dea826811e | ||
|
|
31e20399c8 | ||
|
|
7d0fb710a6 | ||
|
|
f1f528fc42 | ||
|
|
d2024da368 | ||
|
|
654c11300e | ||
|
|
02ae92ba0d | ||
|
|
c9c5c43aa1 | ||
|
|
4e62b89d17 | ||
|
|
d913e50948 | ||
|
|
76bb6b2847 | ||
|
|
0b20436445 | ||
|
|
b8e9793908 | ||
|
|
44f0a4971b | ||
|
|
a8b28136a6 | ||
|
|
cfff6a3302 | ||
|
|
9fc8721fa8 | ||
|
|
7d3c8c521f | ||
|
|
0233df4bf2 | ||
|
|
cf8f5b632f | ||
|
|
4b36ca66aa | ||
|
|
1c16ca3d27 | ||
|
|
8e330a24e9 | ||
|
|
c26b6082a2 | ||
|
|
9f577ed6a2 | ||
|
|
8ea92545df | ||
|
|
914d14ec0d | ||
|
|
7f524d2fd0 | ||
|
|
bb71f141e7 | ||
|
|
1c0994c807 | ||
|
|
2330ed25e9 | ||
|
|
40d4e439a9 | ||
|
|
cb1d2be240 | ||
|
|
e00c99b864 | ||
|
|
23f0c4b76a | ||
|
|
5e4b8de18d | ||
|
|
69b12e327f | ||
|
|
0a331c717e | ||
|
|
e7130ff891 | ||
|
|
9e47133304 | ||
|
|
151dcc3c02 | ||
|
|
d7aeafaf84 | ||
|
|
b08e94405c | ||
|
|
3b0950c39b | ||
|
|
9a51cc88d6 | ||
|
|
f7e8cc99b1 | ||
|
|
7c57f490fe | ||
|
|
65e6a96a65 | ||
|
|
70435b712e | ||
|
|
9b2ee0d0eb | ||
|
|
ab4a7e00e7 | ||
|
|
67c0237ddd | ||
|
|
489465faf0 | ||
|
|
b044a8c067 | ||
|
|
fe66eba87f | ||
|
|
03326242a1 | ||
|
|
94a2686dcb | ||
|
|
4d78041d1d | ||
|
|
fa12afed18 | ||
|
|
cd75e5a4c6 | ||
|
|
f6a100863e | ||
|
|
8b7c1e7f10 | ||
|
|
0876977133 | ||
|
|
d49b4b41de | ||
|
|
75ce3846c3 | ||
|
|
250ff1e327 | ||
|
|
31dd86d8c0 | ||
|
|
b4c1645c7a | ||
|
|
9a24623e35 | ||
|
|
2a7a319d6c | ||
|
|
8ad5c86ff9 | ||
|
|
7900184383 | ||
|
|
5dc1279429 | ||
|
|
170252cfc9 | ||
|
|
9f5e9955d5 | ||
|
|
5786977a51 | ||
|
|
050dcff5bb | ||
|
|
81e89de7ea | ||
|
|
57f8d42a5c | ||
|
|
d5b1f6bff5 | ||
|
|
d6ddd0703b | ||
|
|
b6a5c0f090 | ||
|
|
02b66ddfcf | ||
|
|
5365123d92 | ||
|
|
2ca1716a29 | ||
|
|
f630a3ff81 | ||
|
|
09e3043f4c | ||
|
|
c8bb08e661 | ||
|
|
fbadf9afc8 | ||
|
|
78e3e6b846 | ||
|
|
cc1c5c892d | ||
|
|
f8a718f42e | ||
|
|
9717e59909 | ||
|
|
0f202fd3f9 | ||
|
|
82a5e21ec6 |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(curl -s \"https://api.github.com/search/code?q=WrapTexture+org:Noesis\" -H \"Accept: application/vnd.github+json\")",
|
||||
"Bash(curl -s \"https://api.github.com/orgs/Noesis/repos?per_page=100\")",
|
||||
"WebFetch(domain:wiki.wxwidgets.org)",
|
||||
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/samples.gpu/hello-es-triangle.htm\")",
|
||||
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=include&recursive=true&per_page=100&ref=main\")",
|
||||
"Bash(python3 -c ' *)",
|
||||
"Bash(curl -sL --max-time 40 \"https://api.github.com/repos/wxWidgets/wxWidgets/contents/src?ref=master\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://api.github.com/repos/wxWidgets/wxWidgets/contents/include/wx/android?ref=master\")",
|
||||
"Bash(curl -sL --max-time 40 -H \"Accept: application/vnd.github.text-match+json\" \"https://api.github.com/search/code?q=vulkan+repo:wxWidgets/wxWidgets\")",
|
||||
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/include/sciter-x-video-api.h\")",
|
||||
"WebFetch(domain:docs.wxwidgets.org)",
|
||||
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/CHANGELOG.md\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/readme.txt\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/licence.txt\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/licendu.txt\")",
|
||||
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=build&recursive=true&per_page=100&ref=main\")",
|
||||
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/premake5.lua\")",
|
||||
"WebFetch(domain:slack-chats.kotlinlang.org)",
|
||||
"Bash(curl -sS -L \"https://sciter.com/\")",
|
||||
"Bash(curl -sL --max-time 45 \"https://api.github.com/orgs/ultralight-ux/repos?per_page=100&sort=pushed\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/gdkdmabuftexturebuilder.h\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/gdkgltexturebuilder.h\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/api/v4/projects/GNOME%2Fgtk/repository/tree?path=gdk&ref=main&per_page=100\")",
|
||||
"WebFetch(domain:docs.slint.dev)",
|
||||
"WebFetch(domain:releases.slint.dev)",
|
||||
"WebFetch(domain:flutter.dev)",
|
||||
"Bash(curl -sS -L \"https://sciter.com/forums/topic/status-of-quark-sciter-lite-sciterjs-android-ios/\")",
|
||||
"Bash(curl -sL --max-time 45 \"https://api.github.com/repos/ultralight-ux/AppCore/git/trees/master?recursive=1\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/meson.build\")",
|
||||
"WebFetch(domain:www.jetbrains.com)",
|
||||
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=demos.lite&recursive=true&per_page=100&ref=main\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/api/v4/projects/GNOME%2Fgtk/repository/commits?path=gdk/android/gdkandroidglcontext.c&ref_name=main&per_page=20\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/android/meson.build\")",
|
||||
"WebFetch(domain:docs.sciter.com)",
|
||||
"Bash(curl -sS -L \"https://sciter.com/support-of-displayflex-and-displaygrid-in-sciter/\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Model weights live in LFS.
|
||||
#
|
||||
# `core/dr-segment/models/*.onnx` is ~11 MB of binary that changes wholesale
|
||||
# when it changes at all. In ordinary git objects every future revision of it
|
||||
# would be stored in full, in every clone, forever — and the one thing nobody
|
||||
# can do with it is a useful diff.
|
||||
#
|
||||
# Consequence worth knowing before it bites: a clone without git-lfs gets a
|
||||
# ~130-byte pointer file where the model should be. `dr-segment`'s build script
|
||||
# detects exactly that and fails with an instruction rather than embedding the
|
||||
# pointer and failing at inference time.
|
||||
*.onnx filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -0,0 +1,170 @@
|
||||
name: '🐳 Android image'
|
||||
|
||||
# Builds and pushes gitea.tourolle.paris/dtourolle/darkroom-android, the job
|
||||
# container for the Android leg of build-and-test.yml.
|
||||
#
|
||||
# It exists because that image previously lived only on a developer's laptop:
|
||||
# the workflow referenced a tag that had never been pushed, and every Android
|
||||
# job died at `docker pull` with "manifest unknown" before running a step. The
|
||||
# image is now reproducible from the repo rather than from one machine.
|
||||
#
|
||||
# Called by build-and-test.yml on every push, and runnable by hand via
|
||||
# workflow_dispatch. It is cheap when nothing changed — see the guard below.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Rebuild even if the registry already has this image ("true"/"false")'
|
||||
type: string
|
||||
default: 'false'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Rebuild even if the registry already has this image ("true"/"false")'
|
||||
type: string
|
||||
default: 'false'
|
||||
|
||||
# Gitea's act_runner mangles boolean workflow inputs passed through an
|
||||
# expression — they arrive as false regardless of what was sent. Every input
|
||||
# here is a string compared with == 'true', as in KPN's docker.yaml.
|
||||
|
||||
env:
|
||||
IMAGE: gitea.tourolle.paris/dtourolle/darkroom-android
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux/amd64
|
||||
name: Build and push
|
||||
# Deliberately NOT in a container: this job needs the host Docker daemon to
|
||||
# build an image, and the host's cached ~/.docker/config.json to push it.
|
||||
# That is also why there is no `docker login` step — the runner host was
|
||||
# authenticated to the registry during setup.
|
||||
|
||||
steps:
|
||||
# The host has no Node, so the JS-based actions/checkout cannot run here.
|
||||
# A minimal shallow fetch with plain git gets the same tree.
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -e
|
||||
git init -q .
|
||||
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||
git -c http.extraheader="AUTHORIZATION: basic $(printf '%s' '${{ github.actor }}:${{ github.token }}' | base64 -w0)" \
|
||||
fetch --depth 1 origin "${{ github.sha }}"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
# The image is tagged by the content of docker/android, not by the commit
|
||||
# that happened to touch it. `git rev-parse HEAD:<dir>` is the tree object
|
||||
# id — it changes when and only when a file in that directory changes, so
|
||||
# an unrelated push reuses the existing image and a Dockerfile edit can
|
||||
# never silently keep serving a stale `latest`.
|
||||
#
|
||||
# Using the commit sha instead would rebuild 7 GB on every push; using a
|
||||
# paths-filter action would need a container that has Node, and the only
|
||||
# one this repo would reach for is the very image being built.
|
||||
- name: Resolve image tag
|
||||
id: tag
|
||||
run: |
|
||||
set -e
|
||||
TREE=$(git rev-parse HEAD:docker/android)
|
||||
echo "tree=$TREE" >> "$GITHUB_OUTPUT"
|
||||
echo "docker/android tree: $TREE"
|
||||
|
||||
# Skip the build when the registry already holds this exact content. This
|
||||
# is what keeps the job a few seconds long on a normal push, and what
|
||||
# makes it self-healing: if the tag is missing for any reason, including
|
||||
# the image having never been pushed at all, it gets built here.
|
||||
#
|
||||
# The probe is curl against the registry API, NOT `docker manifest
|
||||
# inspect`. The latter exits 1 on this registry even for tags that are
|
||||
# demonstrably present — jellytau-builder:latest answers HTTP 200 to the
|
||||
# API while `docker manifest inspect` reports "manifest unknown" for it.
|
||||
# Trusting that would have rebuilt 7 GB on every single push.
|
||||
#
|
||||
# A HEAD request also gives the digest for free, which is how the repoint
|
||||
# decision below is made without pulling any layers.
|
||||
- name: Query registry
|
||||
id: check
|
||||
env:
|
||||
# The runner's own credentials, so this does not depend on how the
|
||||
# host's ~/.docker/config.json happens to be set up.
|
||||
REG_USER: ${{ github.actor }}
|
||||
REG_PASS: ${{ github.token }}
|
||||
TREE: ${{ steps.tag.outputs.tree }}
|
||||
run: |
|
||||
set -eu
|
||||
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
API="https://gitea.tourolle.paris/v2/dtourolle/darkroom-android/manifests"
|
||||
|
||||
# Prints "<http-status> <digest-or-empty>" for a tag.
|
||||
probe() {
|
||||
curl -sI -u "$REG_USER:$REG_PASS" -H "Accept: $ACCEPT" "$API/$1" \
|
||||
| tr -d '\r' \
|
||||
| awk 'BEGIN{s="000";d=""} /^HTTP/{s=$2} tolower($1)=="docker-content-digest:"{d=$2} END{print s, d}'
|
||||
}
|
||||
|
||||
read -r TREE_STATUS TREE_DIGEST <<EOF
|
||||
$(probe "$TREE")
|
||||
EOF
|
||||
read -r LATEST_STATUS LATEST_DIGEST <<EOF
|
||||
$(probe latest)
|
||||
EOF
|
||||
|
||||
echo "tag $TREE -> HTTP $TREE_STATUS ${TREE_DIGEST:-(no digest)}"
|
||||
echo "tag latest -> HTTP $LATEST_STATUS ${LATEST_DIGEST:-(no digest)}"
|
||||
|
||||
# Build unless the registry definitively confirms this content is
|
||||
# already there. An auth failure or an unreachable registry lands
|
||||
# here too, and rebuilding needlessly is the safe direction to fail —
|
||||
# skipping a build that was needed is what breaks the Android job.
|
||||
if [ "${{ inputs.force }}" = "true" ]; then
|
||||
echo "forced rebuild requested"
|
||||
echo "build=true" >> "$GITHUB_OUTPUT"
|
||||
echo "repoint=false" >> "$GITHUB_OUTPUT"
|
||||
elif [ "$TREE_STATUS" != "200" ]; then
|
||||
echo "registry does not have this content — building"
|
||||
echo "build=true" >> "$GITHUB_OUTPUT"
|
||||
echo "repoint=false" >> "$GITHUB_OUTPUT"
|
||||
elif [ -n "$TREE_DIGEST" ] && [ "$TREE_DIGEST" = "$LATEST_DIGEST" ]; then
|
||||
echo "registry is already correct — nothing to do"
|
||||
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||
echo "repoint=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "content is present but latest points elsewhere — repointing"
|
||||
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||
echo "repoint=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Context is docker/android, matching the README's build command. The
|
||||
# Dockerfile COPYs nothing from the repo, so it needs no wider context —
|
||||
# and a narrow context keeps the daemon from tarring up the whole tree,
|
||||
# target/ included.
|
||||
- name: Build
|
||||
if: ${{ steps.check.outputs.build == 'true' }}
|
||||
run: |
|
||||
set -e
|
||||
docker build \
|
||||
-t "$IMAGE:${{ steps.tag.outputs.tree }}" \
|
||||
-t "$IMAGE:latest" \
|
||||
docker/android
|
||||
|
||||
# Both tags are pushed: the tree tag is what the guard above looks for on
|
||||
# the next run, and `latest` is what build-and-test.yml pulls.
|
||||
- name: Push
|
||||
if: ${{ steps.check.outputs.build == 'true' }}
|
||||
run: |
|
||||
set -e
|
||||
docker push "$IMAGE:${{ steps.tag.outputs.tree }}"
|
||||
docker push "$IMAGE:latest"
|
||||
|
||||
# A cache hit on the tree tag says nothing about where `latest` points — a
|
||||
# reverted Dockerfile or a build from another branch can leave it on
|
||||
# different content. This runs only when the digests above actually
|
||||
# disagree, so the common case costs nothing; the layers are already in
|
||||
# the registry, so the push that follows uploads a manifest, not 7 GB.
|
||||
- name: Repoint latest
|
||||
if: ${{ steps.check.outputs.repoint == 'true' }}
|
||||
run: |
|
||||
set -e
|
||||
docker pull "$IMAGE:${{ steps.tag.outputs.tree }}"
|
||||
docker tag "$IMAGE:${{ steps.tag.outputs.tree }}" "$IMAGE:latest"
|
||||
docker push "$IMAGE:latest"
|
||||
@@ -0,0 +1,177 @@
|
||||
name: Build and test
|
||||
|
||||
# Desktop and Android are built on every push, per the v0.1 decision to carry
|
||||
# both platforms from the first commit. An Android break is then caught the day
|
||||
# it lands rather than at a porting milestone.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop]
|
||||
pull_request:
|
||||
branches: [main, master, develop]
|
||||
|
||||
jobs:
|
||||
# The Android job runs inside an image that this repo builds. Ensure it is in
|
||||
# the registry before anything tries to pull it — see android-image.yml for
|
||||
# why this is a job rather than a documented manual step. It is a no-op of a
|
||||
# few seconds unless docker/android actually changed.
|
||||
android-image:
|
||||
uses: ./.gitea/workflows/android-image.yml
|
||||
|
||||
desktop:
|
||||
runs-on: linux/amd64
|
||||
name: Desktop (Linux)
|
||||
# actions/checkout and actions/cache are JavaScript actions: the runner
|
||||
# executes them with Node from inside this container. The bare runner image
|
||||
# has none, so the job failed at checkout before reaching any build step.
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: desktop-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
# Slint and winit need these at build time; the runner image is minimal.
|
||||
- name: Build dependencies
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq pkg-config libfontconfig1-dev libxkbcommon-dev
|
||||
|
||||
# The act image ships Node but no Rust. Pinned to the workspace
|
||||
# rust-version so CI, the Android image, and local builds agree — a
|
||||
# floating toolchain turns an unrelated push into a mystery failure.
|
||||
- name: Install Rust 1.92.0
|
||||
run: |
|
||||
set -e
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- \
|
||||
-y --no-modify-path --profile minimal \
|
||||
--default-toolchain 1.92.0 --component rustfmt,clippy
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Format
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
# GPU tests skip themselves where no adapter is present rather than
|
||||
# failing — CI runners generally have none, and a test that cannot run is
|
||||
# not evidence either way.
|
||||
- name: Test
|
||||
run: cargo test --workspace
|
||||
|
||||
- name: Build
|
||||
run: cargo build --workspace --release
|
||||
|
||||
android:
|
||||
runs-on: linux/amd64
|
||||
name: Android (aarch64)
|
||||
# Waits for the image build. Without this the pull races the push and the
|
||||
# job dies with "manifest unknown" before its first step, which is the
|
||||
# failure mode this ordering exists to remove.
|
||||
needs: android-image
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/darkroom-android:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/opt/cargo/registry
|
||||
target-android
|
||||
key: android-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
# Only the core crates cross-compile today; the UI and app crates join
|
||||
# once the Android shell exists (milestone v0.1, FR-PLAT-AND-*).
|
||||
- name: Cross-compile core
|
||||
env:
|
||||
CARGO_TARGET_DIR: target-android
|
||||
run: cargo check -p dr-types -p dr-gpu -p dr-sync --target aarch64-linux-android
|
||||
|
||||
# The linker targets MIN_API, not the compile SDK. cargo-ndk otherwise
|
||||
# defaults to API 21, far below the Vulkan floor this app needs — and the
|
||||
# mismatch is invisible until a device refuses to install.
|
||||
#
|
||||
# Look under the target triple, and fail on a mismatch. Searching the
|
||||
# whole target dir for the first `*.so` found the host proc-macro
|
||||
# libraries in target-android/debug/deps instead — x86-64 objects built
|
||||
# by the runner's gcc, whose .comment section says nothing about Android
|
||||
# and can never contradict the expected API. The step passed regardless
|
||||
# of what the linker actually did, which is the one thing it exists to
|
||||
# rule out.
|
||||
- name: Verify minimum API level
|
||||
env:
|
||||
CARGO_TARGET_DIR: target-android
|
||||
run: |
|
||||
set -e
|
||||
cargo ndk -t arm64-v8a build -p dr-gpu --release
|
||||
MIN_API=$(sed -n 's/^ARG MIN_API=\([0-9]*\).*/\1/p' docker/android/Dockerfile)
|
||||
# Empty on both sides would compare equal and pass, so neither side
|
||||
# is allowed to be the result of a failed parse.
|
||||
if [ -z "$MIN_API" ]; then
|
||||
echo "no ARG MIN_API= in docker/android/Dockerfile"
|
||||
exit 1
|
||||
fi
|
||||
SO=$(find target-android/aarch64-linux-android/release -maxdepth 1 -name '*.so' | head -1)
|
||||
if [ -z "$SO" ]; then
|
||||
echo "no aarch64 .so was produced"
|
||||
exit 1
|
||||
fi
|
||||
echo "checking $SO"
|
||||
file "$SO"
|
||||
API=$(file "$SO" | sed -n 's/.*for Android \([0-9]*\).*/\1/p')
|
||||
if [ -z "$API" ] || [ "$API" != "$MIN_API" ]; then
|
||||
echo "FAIL: linked for Android '${API:-unknown}', expected $MIN_API"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
layering:
|
||||
runs-on: linux/amd64
|
||||
name: Layer separation
|
||||
# Node for the JS actions, as above. cargo comes from rustup below.
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# `cargo tree` resolves the dependency graph, so it needs the registry
|
||||
# index but no system libraries — this job builds nothing.
|
||||
- name: Install Rust 1.92.0
|
||||
run: |
|
||||
set -e
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- \
|
||||
-y --no-modify-path --profile minimal --default-toolchain 1.92.0
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# ARCH §6.5a: no core/ crate may depend on the UI toolkit. One stray
|
||||
# `use slint::` costs headless golden-image testing and the
|
||||
# one-operation-two-presentations property together, and nothing else
|
||||
# would notice.
|
||||
- name: Core crates must not depend on the UI
|
||||
run: |
|
||||
set -e
|
||||
FAILED=0
|
||||
for crate in dr-types dr-gpu dr-sync; do
|
||||
if cargo tree -p "$crate" -e normal 2>/dev/null | grep -qE '\bslint\b|\bi-slint'; then
|
||||
echo "FAIL: $crate depends on Slint (ARCH §6.5a)"
|
||||
FAILED=1
|
||||
else
|
||||
echo "ok: $crate"
|
||||
fi
|
||||
done
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,112 @@
|
||||
name: Traceability
|
||||
|
||||
# Mirrors JellyTau's traceability gate, including the reason it exists.
|
||||
#
|
||||
# That gate divided a traced count by frozen literal denominators while the
|
||||
# requirements file grew past them, reported 158% coverage, and so could never
|
||||
# fail its own threshold. Two rules follow, and the extractor's own tests
|
||||
# enforce both:
|
||||
#
|
||||
# 1. Denominators are parsed from docs/requirements.md at run time.
|
||||
# 2. Coverage is |traced ∩ defined| / |defined|, never a raw traced count.
|
||||
#
|
||||
# This job is static analysis of source comments plus markdown parsing, so it
|
||||
# needs no GPU and no Android SDK — only the Rust toolchain.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop]
|
||||
pull_request:
|
||||
branches: [main, master, develop]
|
||||
|
||||
jobs:
|
||||
traceability:
|
||||
runs-on: linux/amd64
|
||||
name: Requirement traces
|
||||
# Node for actions/checkout and actions/cache, which the bare runner image
|
||||
# cannot execute. Rust is installed below.
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: traces-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
# Source-comment and markdown parsing only, so the minimal profile is
|
||||
# enough — no system libraries and no components beyond cargo itself.
|
||||
- name: Install Rust 1.92.0
|
||||
run: |
|
||||
set -e
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- \
|
||||
-y --no-modify-path --profile minimal --default-toolchain 1.92.0
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The gate's own arithmetic is the thing being trusted, so its tests run
|
||||
# before it does. Untested gate logic is exactly how JellyTau's 158% went
|
||||
# unnoticed for months.
|
||||
- name: Test the extractor
|
||||
run: cargo test -p traceability
|
||||
|
||||
# Structural failures are unconditional and do not depend on the coverage
|
||||
# threshold: zero requirements parsed, zero files scanned, a ratio above
|
||||
# 100%, or any orphan tag all fail the build. A misconfigured run must not
|
||||
# report a plausible-looking 0%.
|
||||
- name: Traceability gate
|
||||
run: cargo run -q -p traceability -- check
|
||||
|
||||
- name: Regenerate matrix and check it is committed
|
||||
run: |
|
||||
set -e
|
||||
cargo run -q -p traceability -- report
|
||||
if ! git diff --quiet docs/traceability.md; then
|
||||
echo ""
|
||||
echo "docs/traceability.md is out of date."
|
||||
echo "Run: cargo run -p traceability -- report"
|
||||
git diff --stat docs/traceability.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Advisory, not blocking: not every file implements a requirement, and a
|
||||
# tag on every function is noise that rots faster than it helps. Tag the
|
||||
# unit that decides.
|
||||
- name: Check changed files for tags
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
set -e
|
||||
CHANGED=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" \
|
||||
| grep -E '\.(rs|slint|wgsl)$' || true)
|
||||
[ -z "$CHANGED" ] && { echo "No source files changed."; exit 0; }
|
||||
|
||||
MISSING=0
|
||||
for file in $CHANGED; do
|
||||
case "$file" in
|
||||
*/tests/*|*/test_*|tools/*) continue ;;
|
||||
esac
|
||||
[ -f "$file" ] || continue
|
||||
if ! grep -q 'TRACES:' "$file"; then
|
||||
echo " no TRACES tag: $file"
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "$MISSING changed file(s) carry no requirement tag."
|
||||
echo "Format: /// TRACES: FR-CAT-1, FR-CAT-2 | NFR-P1"
|
||||
echo " (comma separates IDs, pipe groups types)"
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: head -30 docs/traceability.md || true
|
||||
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
/target-android
|
||||
Cargo.lock.bak
|
||||
*.log
|
||||
@@ -0,0 +1,212 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"core/dr-types",
|
||||
"core/dr-catalog",
|
||||
"core/dr-thumbs",
|
||||
"core/dr-decode",
|
||||
"core/dr-export",
|
||||
"core/dr-ingest",
|
||||
"core/dr-gpu",
|
||||
"core/dr-lens",
|
||||
"core/dr-pipeline",
|
||||
"core/dr-segment",
|
||||
"core/dr-sync",
|
||||
"core/dr-sync-nextcloud",
|
||||
"platform/dr-plat",
|
||||
"ui/dr-ui",
|
||||
"apps/darkroom-desktop",
|
||||
"apps/darkroom-android",
|
||||
"tools/traceability",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "GPL-3.0-or-later"
|
||||
repository = "https://github.com/dtourolle/DarkRoom"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Internal
|
||||
dr-types = { path = "core/dr-types" }
|
||||
dr-catalog = { path = "core/dr-catalog" }
|
||||
dr-thumbs = { path = "core/dr-thumbs" }
|
||||
dr-decode = { path = "core/dr-decode" }
|
||||
dr-export = { path = "core/dr-export" }
|
||||
dr-ingest = { path = "core/dr-ingest" }
|
||||
dr-gpu = { path = "core/dr-gpu" }
|
||||
dr-lens = { path = "core/dr-lens" }
|
||||
dr-pipeline = { path = "core/dr-pipeline" }
|
||||
# `default-features = false` belongs *here*, not on each dependant: a member
|
||||
# inheriting a workspace dependency cannot turn its default features off, so
|
||||
# writing it below would silently do nothing and every crate touching
|
||||
# `dr-segment` would drag in tract and 11 MB of weights. Members opt in with
|
||||
# `features = ["semantic", "embedded-model"]` instead.
|
||||
dr-segment = { path = "core/dr-segment", default-features = false }
|
||||
dr-plat = { path = "platform/dr-plat" }
|
||||
dr-sync = { path = "core/dr-sync" }
|
||||
dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" }
|
||||
dr-ui = { path = "ui/dr-ui" }
|
||||
|
||||
# GPU + UI
|
||||
#
|
||||
# The wgpu version is not a free choice: it is dictated by Slint. Importing a
|
||||
# texture into the scene (ARCH §6.1, spike S1) requires it to come from the
|
||||
# *same* `wgpu::Device` Slint renders with, and Slint will only hand out a
|
||||
# device of the version it was compiled against. Slint 1.17 offers
|
||||
# `unstable-wgpu-28` and `unstable-wgpu-29` and nothing older, so 29 it is —
|
||||
# pinned to the same `29.0.4` floor Slint itself requires, because two
|
||||
# semver-compatible-but-different wgpu crates in one tree are two *types*, and
|
||||
# the device would not typecheck across them.
|
||||
#
|
||||
# Consequently: bumping Slint may force a wgpu bump, and wgpu cannot be bumped
|
||||
# on its own. They move together or not at all.
|
||||
wgpu = "29.0.4"
|
||||
slint = { version = "1.17", default-features = false }
|
||||
slint-build = "1.17"
|
||||
|
||||
# UI token codegen (S2): style.yaml -> theme.slint. serde_yaml was deprecated
|
||||
# by its maintainer in 2024 and serde_yml, the first fork, has since been
|
||||
# deprecated too; serde_norway is the fork still receiving releases. Its
|
||||
# mappings preserve insertion order, which is what lets the generated Slint
|
||||
# keep the token ordering the YAML author chose.
|
||||
serde_norway = "0.9"
|
||||
|
||||
# Foundations
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
pollster = "0.4"
|
||||
|
||||
# Networking — no mature Nextcloud crate exists; the connector is hand-rolled
|
||||
# over reqwest (D7). reqwest_dav was evaluated and is too thin to build on.
|
||||
# `rustls-no-provider` rather than `rustls`: the latter defaults to the
|
||||
# aws-lc-rs crypto provider, whose aws-lc-sys crate is C and fails to
|
||||
# cross-compile for Android — precisely the NDK pain D1 chose Rust to avoid.
|
||||
# ring is pure Rust apart from a small asm core that does build under the NDK.
|
||||
#
|
||||
# `rustls-tls-webpki-roots-no-provider` rather than plain `rustls-no-provider`:
|
||||
# the latter verifies against rustls-platform-verifier, which reaches the
|
||||
# Android trust store over JNI and panics mid-handshake unless initialised from
|
||||
# Java first — the crash D7 predicted and spike S3 exists to resolve properly.
|
||||
# The panic surfaces inside tokio, which catches task panics itself, so it
|
||||
# reaches the UI as a worker that stopped rather than as an error.
|
||||
#
|
||||
# webpki-roots is the escape hatch D7 records: a root store compiled into the
|
||||
# binary, no JNI, identical on both platforms. The trade is real and belongs in
|
||||
# S3's scope — user-installed and enterprise CAs are not consulted, and the
|
||||
# roots go stale with the release rather than with the OS.
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "webpki-roots", "stream", "json"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
quick-xml = "0.41"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
url = "2.5"
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.23"
|
||||
|
||||
# Platform secure storage: Secret Service on Linux, Keystore on Android
|
||||
# (FR-NC-2). Credentials never touch the catalog or a plain file.
|
||||
# keyring 4 restructured its features: `v1` is the default set and brings
|
||||
# the zbus Secret Service backend, which is what GNOME Keyring and KWallet
|
||||
# (via ksecretd) both speak.
|
||||
keyring = { version = "4", features = ["v1"] }
|
||||
|
||||
# The Android half of the same project: a keyring-core CredentialStore backed
|
||||
# by AndroidKeyStore AES-GCM over SharedPreferences (FR-PLAT-AND-1). It reads
|
||||
# the JavaVM and Context from ndk-context, which android-activity populates
|
||||
# before `android_main` runs, so no Kotlin shim of our own is needed.
|
||||
#
|
||||
# This is the keyring-core API, not the v1 `Entry` API the Linux path uses;
|
||||
# the two impls are deliberately separate rather than sharing a code path.
|
||||
android-native-keyring-store = "1.0.0"
|
||||
keyring-core = "1"
|
||||
|
||||
# Decode. rawler is the pure-Rust decoder (D2); zune-jpeg decodes the
|
||||
# embedded previews rawler extracts.
|
||||
# Catalog. `bundled` compiles SQLite from source rather than linking the
|
||||
# system library — the same cross-compilation reasoning as the TLS choice
|
||||
# above: no system dependency to satisfy under the Android NDK.
|
||||
#
|
||||
# `backup` is not optional in practice: it is what takes a consistent snapshot
|
||||
# of a live WAL database for upload. A filesystem copy of `catalog.sqlite`
|
||||
# while a `-wal` exists beside it uploads a torn file.
|
||||
rusqlite = { version = "0.40", features = ["bundled", "backup"] }
|
||||
|
||||
rawler = "0.7"
|
||||
zune-jpeg = "0.4.21"
|
||||
# Thumbnails are stored encoded, not as raw RGBA: a 256px RGBA buffer is
|
||||
# ~256 KB against ~20 KB as JPEG, and the store syncs to Nextcloud where that
|
||||
# 13× is transfer cost on every client. Pure Rust, no C dependency — the same
|
||||
# criterion behind the TLS and SQLite choices above.
|
||||
jpeg-encoder = "0.7"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
|
||||
# Lens correction profiles. A pure-Rust port of Lensfun rather than a binding
|
||||
# to the C library, for the same cross-compilation reason as the TLS and
|
||||
# SQLite choices above: liblensfun would be a third C dependency to satisfy
|
||||
# under the Android NDK.
|
||||
#
|
||||
# The database ships *inside* the crate — 56 XML files, gzipped at build time
|
||||
# and decompressed on first lookup. That matters beyond convenience: Android
|
||||
# gives us no filesystem path (ARCH §6.9), so a database loaded from a
|
||||
# system directory would have nowhere to live there.
|
||||
#
|
||||
# Licence: LGPL-3.0-or-later, which upgrades cleanly into our GPLv3 (D8).
|
||||
# The upstream Lensfun *database* is CC-BY-SA and is redistributed by the
|
||||
# crate; attribution belongs in the about screen.
|
||||
#
|
||||
# Caveat worth remembering: this is a third-party port at 0.7.0, not upstream
|
||||
# Lensfun. Verified working against the bundled database (interpolation
|
||||
# between calibration points, and an unknown lens returning empty rather than
|
||||
# panicking), but the pipeline talks to it through its own profile types so
|
||||
# swapping it out is not a pipeline change.
|
||||
lensfun = "0.7"
|
||||
|
||||
# Neural inference for semantic segmentation (S15 arm B, D14).
|
||||
#
|
||||
# D13 framed this as a choice between `ort` (fast, best operator coverage, and
|
||||
# a C++ dependency to cross-compile under the NDK) and a pure-Rust runtime
|
||||
# (policy-compliant, unproven coverage). That framing turned out to be a false
|
||||
# choice: `ort` 2.0's `alternative-backend` feature *disables the linking
|
||||
# entirely* and lets a different engine supply the `OrtApi`, and `ort-tract` —
|
||||
# same authors, MIT/Apache — supplies it from `tract`, which is pure Rust.
|
||||
#
|
||||
# So we get `ort`'s API with no C at all. `download-binaries` and `tls-native`
|
||||
# are off with `default-features = false`, which is the point: nothing is
|
||||
# fetched at build time and nothing is linked, so the Android cross-compile
|
||||
# sees an ordinary Rust dependency graph. That is the same reasoning as rustls
|
||||
# over aws-lc-rs and bundled SQLite, applied to inference — D13's largest
|
||||
# tolerated exception turns out not to be needed.
|
||||
#
|
||||
# The trade is real and belongs on the record: tract is slower than the C++
|
||||
# runtime and covers fewer operators. Both were measured rather than assumed
|
||||
# before this landed — yolo26n-seg loads with **zero unsupported operators**
|
||||
# and runs 640x640 in ~470 ms on the reference desktop's CPU. That is fine for
|
||||
# a once-per-image precompute off the frame path (ARCH §6.1) and would not be
|
||||
# fine for anything per-frame, which is a constraint on what may be built on
|
||||
# top rather than on this choice.
|
||||
#
|
||||
# Pinned to an rc: `ort` 2.0 has been in rc for a long while and `ort-tract`
|
||||
# exists only against it. Worth revisiting at 2.0 final.
|
||||
ort = { version = "2.0.0-rc.13", default-features = false, features = ["alternative-backend", "ndarray", "std"] }
|
||||
ort-tract = "0.4"
|
||||
# Not a free choice: it is the version `ort` exposes its tensors through, so
|
||||
# two semver-incompatible ndarrays would not typecheck across the boundary —
|
||||
# the same coupling wgpu has with Slint above.
|
||||
ndarray = "0.17"
|
||||
|
||||
[profile.dev]
|
||||
# Dependencies optimised even in dev builds — wgpu and image decoding are
|
||||
# unusably slow otherwise, and they rarely need debugging.
|
||||
opt-level = 0
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,48 @@
|
||||
# DarkRoom
|
||||
|
||||
A cross-platform, non-destructive RAW photo editor for Linux and Android.
|
||||
|
||||
**Status:** early. v0.1 is a remote library viewer — see
|
||||
[docs/milestone-v0.1.md](docs/milestone-v0.1.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Contents |
|
||||
|---|---|
|
||||
| [requirements.md](docs/requirements.md) | What the software must do — 122 numbered requirements |
|
||||
| [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync |
|
||||
| [milestone-v0.1.md](docs/milestone-v0.1.md) | The first buildable milestone |
|
||||
|
||||
## Building
|
||||
|
||||
Desktop:
|
||||
|
||||
```bash
|
||||
cargo run -p darkroom-desktop
|
||||
```
|
||||
|
||||
Android (containerised toolchain, see [docker/android](docker/android/README.md)):
|
||||
|
||||
```bash
|
||||
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
|
||||
```
|
||||
|
||||
## Current state
|
||||
|
||||
Working: workspace, GPU context and compute pass, adaptive Slint shell, Android
|
||||
cross-compilation of the core crates.
|
||||
|
||||
**Not yet working:** the zero-copy display path. The build currently uploads
|
||||
frames through the CPU, which is exactly what
|
||||
[ARCH §6.1](docs/architecture.md) forbids — measured at 96% of frame time at
|
||||
4K. Replacing it is spike S1, the project's highest priority.
|
||||
|
||||
```
|
||||
cargo run -p dr-gpu --example bench --features readback
|
||||
```
|
||||
|
||||
reproduces that measurement.
|
||||
|
||||
## Licence
|
||||
|
||||
GPL-3.0-or-later.
|
||||
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "darkroom-android"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
# A cdylib, not a bin: Android loads the app as a shared library and calls
|
||||
# `android_main` through android-activity's glue. Nothing execs a binary, so
|
||||
# there is no `main` to provide.
|
||||
[lib]
|
||||
name = "darkroom"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# No backend feature to select: dr-ui picks its Slint backend from the target,
|
||||
# so building for aarch64-linux-android gets android-activity automatically.
|
||||
dr-ui.workspace = true
|
||||
# For `session::set_data_dir`: only the platform entry point knows where Android
|
||||
# lets this app keep files, and it must be set before any store is opened.
|
||||
dr-sync-nextcloud.workspace = true
|
||||
# Directly, not just through dr-ui: `android_main` takes an `AndroidApp` and
|
||||
# calls `slint::android::init`, both of which come from this crate. The backend
|
||||
# feature comes from dr-ui's target-specific dependency.
|
||||
slint.workspace = true
|
||||
log.workspace = true
|
||||
android_logger = "0.15"
|
||||
|
||||
[features]
|
||||
# Mirrors darkroom-desktop: the CPU readback path is gone since S1 landed
|
||||
# zero-copy. It mattered more here than on desktop — the same wrong path with
|
||||
# far less memory bandwidth to absorb it (ARCH §6.1) — but it is untested on a
|
||||
# device, since S1 was verified on desktop only.
|
||||
default = []
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
DarkRoom Android manifest.
|
||||
|
||||
Deliberately minimal: this packages the viewer for on-device testing (spike
|
||||
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
|
||||
here is a distribution manifest yet. Only network access is declared: file
|
||||
access needs no manifest permission because the library grid reads through
|
||||
SAF, which grants per-tree at runtime (ARCH §6.9).
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="paris.tourolle.darkroom">
|
||||
|
||||
<!-- Everything the app does with a server needs this: Login Flow v2, the
|
||||
WebDAV listing, thumbnail and image fetches. Without it Android refuses
|
||||
socket creation outright, and the failure is invisible — no panic to
|
||||
catch, no log line, just a worker thread that stops. Storage is the
|
||||
separate case that genuinely needs no permission here, because SAF
|
||||
grants per-tree at runtime (ARCH §6.9). -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
|
||||
work on unmetered-and-charging, which means knowing the network type. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
|
||||
dependable (NFR-COMPAT-1). Marked required so an unsupported device
|
||||
fails at install rather than at first frame. -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.vulkan.version"
|
||||
android:version="0x00401000"
|
||||
android:required="true" />
|
||||
|
||||
<!-- One name covers both icon generations, which is the point of the
|
||||
`anydpi-v26` qualifier: @mipmap/ic_launcher resolves to the adaptive
|
||||
icon at res/mipmap-anydpi-v26/ic_launcher.xml on API 26 and up, and to
|
||||
the density-matched ic_launcher.png below that. Since minSdk is 28 the
|
||||
PNGs are only ever reached by tooling, but they cost little and aapt2
|
||||
wants a real drawable behind the name. `roundIcon` is deliberately
|
||||
absent: it predates adaptive icons and a launcher that reads it would
|
||||
also be one that ignores the XML, which no device here is.
|
||||
|
||||
The adaptive icon has three layers rather than two. The third,
|
||||
monochrome, is what lets Android 13's themed-icon setting recolour it
|
||||
instead of dropping the app out of the themed set. -->
|
||||
<application
|
||||
android:label="DarkRoom"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:hasCode="true"
|
||||
android:allowBackup="false"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
|
||||
glue loads libdarkroom.so and calls android_main. `android.app.lib_name`
|
||||
is how it learns which library to load, and must match [lib].name. -->
|
||||
<activity
|
||||
android:name="android.app.NativeActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|density|uiMode"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.lib_name"
|
||||
android:value="darkroom" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 518 B |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 343 B |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 680 B |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1005 B |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 151 KiB |
@@ -0,0 +1,63 @@
|
||||
//! DarkRoom Android entry point.
|
||||
//!
|
||||
//! The counterpart to `darkroom-desktop`'s `main`, with two differences that
|
||||
//! come from the platform rather than from choice:
|
||||
//!
|
||||
//! * There are no command-line paths. Android's SAF hands out document URIs,
|
||||
//! not filesystem paths (ARCH §6.9), so the viewer opens with an empty
|
||||
//! browsing list and the library grid is the only way in.
|
||||
//! * Logging goes to logcat. `env_logger` writes to stderr, which Android
|
||||
//! discards.
|
||||
|
||||
// `slint::android` exists only when compiling for Android, so the whole entry
|
||||
// point is gated on the target rather than on a feature. Without this the
|
||||
// crate is still a workspace member on the host, and `cargo test --workspace`
|
||||
// fails to compile it — a build break that only ever appears off-device.
|
||||
#[cfg(target_os = "android")]
|
||||
/// TRACES: M-13 | M-14
|
||||
/// Android application entry point, called by android-activity's glue.
|
||||
#[no_mangle]
|
||||
fn android_main(app: slint::android::AndroidApp) {
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Info)
|
||||
.with_tag("DarkRoom"),
|
||||
);
|
||||
|
||||
// Panics go to stderr, and Android discards stderr. Without this hook a
|
||||
// worker thread that panics is invisible: the process survives, the
|
||||
// channel it was writing to closes, and the UI reports only that
|
||||
// something "failed unexpectedly" with no way to find out what.
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
log::error!("panic: {info}");
|
||||
}));
|
||||
|
||||
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Before anything opens a store: Android has no $HOME and no XDG
|
||||
// directories, so the default guess resolves to a path the app cannot
|
||||
// write. Nothing failed loudly — the session list went to a doomed path, so
|
||||
// the account survived only as long as the process and backgrounding the app
|
||||
// lost the sign-in. `internal_data_path` is the app's private directory
|
||||
// (ARCH §6.9).
|
||||
match app.internal_data_path() {
|
||||
Some(dir) => {
|
||||
log::info!("data dir: {}", dir.display());
|
||||
dr_sync_nextcloud::session::set_data_dir(dir);
|
||||
}
|
||||
None => log::error!("no internal data path; settings will not persist"),
|
||||
}
|
||||
|
||||
if let Err(e) = slint::android::init(app) {
|
||||
log::error!("Slint Android backend failed to initialise: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty rather than the desktop's argv: see the module note above.
|
||||
//
|
||||
// Returning from `android_main` ends the process, so a failure here is
|
||||
// logged rather than propagated — there is no shell to show `Err` to.
|
||||
if let Err(e) = dr_ui::run(Vec::new()) {
|
||||
log::error!("DarkRoom exited with error: {e:#}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "darkroom-desktop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-ui.workspace = true
|
||||
anyhow.workspace = true
|
||||
env_logger.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -0,0 +1,21 @@
|
||||
//! DarkRoom desktop entry point.
|
||||
//!
|
||||
//! darkroom-desktop <file-or-directory>...
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
|
||||
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn",
|
||||
))
|
||||
.init();
|
||||
|
||||
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
let paths: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
||||
if paths.is_empty() {
|
||||
eprintln!("usage: darkroom-desktop <file-or-directory>...");
|
||||
}
|
||||
|
||||
dr_ui::run(paths)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "dr-catalog"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
# The `Storage` trait, and nothing else from it. A scan has to read a real
|
||||
# directory, and this is how `core/` reaches the platform without a
|
||||
# `#[cfg(target_os)]` of its own (ARCH §4.1: calls go downward).
|
||||
dr-plat.workspace = true
|
||||
rusqlite.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
# `collections.selector_json` — the stored form of a smart collection's
|
||||
# selector. The column predates this dependency; nothing else here is JSON.
|
||||
serde_json.workspace = true
|
||||
|
||||
# For the `scan_local` example only, which is a diagnostic tool: what it is
|
||||
# diagnosing is often a folder the scan warned about and skipped, and those
|
||||
# warnings go to `log`.
|
||||
[dev-dependencies]
|
||||
env_logger.workspace = true
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Scan a real folder on this machine into a catalog, and say what it cost.
|
||||
//!
|
||||
//! cargo run -p dr-catalog --example scan_local -- ~/Pictures [catalog.sqlite]
|
||||
//!
|
||||
//! **Run it twice.** The first run is a full walk; the second is the one worth
|
||||
//! watching, because on an unchanged library it should list no directories at
|
||||
//! all and take a fraction of the time. That difference is NFR-P1, and a
|
||||
//! synthetic test cannot show it at the scale a real library does — 121,785
|
||||
//! files in a synced folder is a different question from twenty in a temporary
|
||||
//! directory.
|
||||
//!
|
||||
//! Writes only to the catalog file, which defaults to a fixed path in the
|
||||
//! system temporary directory so a second run has something to compare
|
||||
//! against. Nothing in the scanned folder is touched.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dr_catalog::walk::{ensure_root, scan_root, RootKind};
|
||||
use dr_catalog::Catalog;
|
||||
use dr_plat::LocalStorage;
|
||||
use dr_types::FormatFilter;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
let Some(dir) = args.next().map(PathBuf::from) else {
|
||||
eprintln!("usage: scan_local <directory> [catalog.sqlite]");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let catalog_path = args
|
||||
.next()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-scan-local.sqlite"));
|
||||
|
||||
let catalog = match Catalog::open(&catalog_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("cannot open {}: {e}", catalog_path.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
println!("catalog: {}", catalog_path.display());
|
||||
|
||||
// The label is how the grant is spelled, and the only place a path is
|
||||
// written down. Everything after this line addresses files by `RootId`.
|
||||
let label = dir.display().to_string();
|
||||
let root = match ensure_root(catalog.connection(), RootKind::Local, &label) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("cannot record the root: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let storage = LocalStorage::with_root(root, &dir);
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let report = match scan_root(
|
||||
catalog.connection(),
|
||||
&storage,
|
||||
root,
|
||||
&FormatFilter::all(),
|
||||
now,
|
||||
|| false,
|
||||
|p| {
|
||||
// One line per hundred directories: enough to show it is alive on a
|
||||
// large library, not enough to be the thing that slows it down.
|
||||
let visited = p.directories_listed + p.directories_pruned;
|
||||
if visited % 100 == 0 {
|
||||
println!(
|
||||
" … {visited} directories ({} pruned), {} images",
|
||||
p.directories_pruned, p.images_found
|
||||
);
|
||||
}
|
||||
},
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("scan failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
let total: i64 = catalog
|
||||
.connection()
|
||||
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
|
||||
.unwrap_or(-1);
|
||||
|
||||
println!("\noutcome: {:?}", report.outcome);
|
||||
println!(
|
||||
"directories: {} listed, {} pruned",
|
||||
report.progress.directories_listed, report.progress.directories_pruned
|
||||
);
|
||||
println!(
|
||||
"images: {} new, {} changed, {} unchanged, {} removed",
|
||||
report.inserted, report.updated, report.unchanged, report.images_removed
|
||||
);
|
||||
println!("folders: {} removed", report.folders_removed);
|
||||
println!("catalogued: {total} in total");
|
||||
println!("took: {:.2?}", elapsed);
|
||||
|
||||
if report.progress.directories_listed == 0 && report.progress.directories_pruned > 0 {
|
||||
println!("\nnothing had changed: every folder was proven unchanged by one probe");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,986 @@
|
||||
//! TRACES: FR-NC-6a | FR-CAT-9 | NFR-RES-4
|
||||
//! Which originals are kept on this device, and which may be evicted.
|
||||
//!
|
||||
//! # Two populations, one table
|
||||
//!
|
||||
//! An original ends up here two ways, and conflating them produces exactly the
|
||||
//! failure the whole feature exists to prevent.
|
||||
//!
|
||||
//! **Pinned** originals were asked for. A user pins a collection before a trip
|
||||
//! and expects those photographs to be there when there is no connection —
|
||||
//! that is a promise, so pinned rows are never evicted and never counted
|
||||
//! against the budget. A cap that could silently delete a pinned trip would
|
||||
//! make pinning worthless, because the user could not rely on it without
|
||||
//! checking.
|
||||
//!
|
||||
//! **Passively cached** originals are a side effect of working: opening an
|
||||
//! image in develop downloads it, so keeping the bytes costs nothing extra and
|
||||
//! saves the whole transfer next time. This population is bounded by
|
||||
//! [`Budget`] and evicted least-recently-used, because it grows without limit
|
||||
//! otherwise — a day of culling would fill a disk.
|
||||
//!
|
||||
//! The two budgets are separate rather than shared. Sharing them means a large
|
||||
//! pin starves the passive cache, or worse, that browsing evicts a pin.
|
||||
//!
|
||||
//! # What this module does and does not own
|
||||
//!
|
||||
//! It owns the *bookkeeping*: which images are held, at what tier, how large,
|
||||
//! when last used, and which are pinned. The bytes are files under a cache
|
||||
//! directory, and [`store`](Cache::store) writes them; but deciding to
|
||||
//! download something is the caller's business, because that needs a network
|
||||
//! and this crate has none.
|
||||
//!
|
||||
//! # Why `tier_actual` is the truth
|
||||
//!
|
||||
//! `tier_desired` is what a pin asks for; `tier_actual` is what is on disk.
|
||||
//! Only the second answers "can this be opened right now", which is the
|
||||
//! question offline mode asks (FR-CAT-9). A pinned image whose download has
|
||||
//! not run yet is precisely the one that would fail, so it must not report as
|
||||
//! available.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dr_types::{ImageId, Tier};
|
||||
use rusqlite::{Connection, OptionalExtension as _};
|
||||
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// Default ceiling for passively cached originals.
|
||||
///
|
||||
/// 1 GB holds roughly 30 full-frame RAWs — a working session's worth, which is
|
||||
/// what this cache is for. It is deliberately modest: the passive cache is a
|
||||
/// convenience that should not quietly consume a disk, and a user who wants
|
||||
/// more kept is better served by pinning, which says so explicitly and is not
|
||||
/// subject to eviction at all.
|
||||
pub const DEFAULT_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
|
||||
/// How much disk the passive cache may use.
|
||||
///
|
||||
/// A newtype rather than a bare `u64` so a byte count cannot be passed where a
|
||||
/// budget belongs, and to give the "unlimited" case a name — some users have a
|
||||
/// large disk and would rather never re-download.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Budget(Option<u64>);
|
||||
|
||||
impl Default for Budget {
|
||||
fn default() -> Self {
|
||||
Self::bytes(DEFAULT_BUDGET_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
pub fn bytes(n: u64) -> Self {
|
||||
Self(Some(n))
|
||||
}
|
||||
|
||||
/// No ceiling: nothing is ever evicted for space.
|
||||
pub fn unlimited() -> Self {
|
||||
Self(None)
|
||||
}
|
||||
|
||||
pub fn limit(self) -> Option<u64> {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// How much must be freed to fit `used` within this budget.
|
||||
fn overage(self, used: u64) -> u64 {
|
||||
self.0.map_or(0, |cap| used.saturating_sub(cap))
|
||||
}
|
||||
}
|
||||
|
||||
/// What is held for one image.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub image: ImageId,
|
||||
/// What is actually on disk.
|
||||
pub tier: Tier,
|
||||
/// What a pin has asked for, which may be ahead of `tier`.
|
||||
pub desired: Tier,
|
||||
pub bytes: u64,
|
||||
/// Unix seconds, or `None` if never read back since being stored.
|
||||
pub last_used: Option<i64>,
|
||||
pub pinned: bool,
|
||||
/// Path relative to the cache directory.
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// How the cache is currently filled.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Usage {
|
||||
/// Bytes held by pinned originals. Not subject to the budget.
|
||||
pub pinned_bytes: u64,
|
||||
/// Bytes held by passively cached originals. What the budget bounds.
|
||||
pub passive_bytes: u64,
|
||||
pub pinned_count: usize,
|
||||
pub passive_count: usize,
|
||||
}
|
||||
|
||||
impl Usage {
|
||||
pub fn total_bytes(self) -> u64 {
|
||||
self.pinned_bytes + self.passive_bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// The on-disk cache of originals, rooted at a directory.
|
||||
pub struct Cache {
|
||||
dir: PathBuf,
|
||||
budget: Budget,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
/// Open a cache rooted at `dir`, creating it if needed.
|
||||
pub fn open(dir: &Path, budget: Budget) -> Result<Self, CatalogError> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| CatalogError::Io(format!("creating {}: {e}", dir.display())))?;
|
||||
Ok(Self {
|
||||
dir: dir.to_path_buf(),
|
||||
budget,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
pub fn budget(&self) -> Budget {
|
||||
self.budget
|
||||
}
|
||||
|
||||
/// Absolute path for a cached original.
|
||||
///
|
||||
/// Named by image id rather than by the remote filename: two folders on
|
||||
/// the server may hold `IMG_0001.CR2`, and a flat cache keyed on the name
|
||||
/// would have them overwrite each other. The extension is preserved so the
|
||||
/// decoder's format probe sees what it expects.
|
||||
fn relative_path(image: ImageId, source_ref: &str) -> String {
|
||||
let ext = source_ref
|
||||
.rsplit_once('.')
|
||||
.map(|(_, e)| e.to_ascii_lowercase())
|
||||
.filter(|e| {
|
||||
!e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
})
|
||||
.unwrap_or_else(|| "bin".to_string());
|
||||
format!("{}.{ext}", image.0)
|
||||
}
|
||||
|
||||
/// Store an original's bytes and record it.
|
||||
///
|
||||
/// `pinned` says which population this belongs to. Storing an image that
|
||||
/// is already present updates it rather than duplicating — the same
|
||||
/// photograph opened twice is one cache entry, and the second store simply
|
||||
/// refreshes the bytes and the timestamp.
|
||||
///
|
||||
/// Does **not** evict. The caller runs [`enforce`](Self::enforce) once it
|
||||
/// has finished storing, so a batch of downloads is trimmed once rather
|
||||
/// than after every file.
|
||||
pub fn store(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
image: ImageId,
|
||||
source_ref: &str,
|
||||
bytes: &[u8],
|
||||
pinned: bool,
|
||||
now: i64,
|
||||
) -> Result<(), CatalogError> {
|
||||
let rel = Self::relative_path(image, source_ref);
|
||||
let abs = self.dir.join(&rel);
|
||||
|
||||
// Written to a temporary and renamed, so a crash or a dropped
|
||||
// connection mid-write cannot leave a truncated file that the catalog
|
||||
// records as a complete original — which would then fail to decode
|
||||
// with no indication that the *cache* was at fault rather than the
|
||||
// photograph.
|
||||
let tmp = abs.with_extension("partial");
|
||||
std::fs::write(&tmp, bytes)
|
||||
.map_err(|e| CatalogError::Io(format!("writing {}: {e}", tmp.display())))?;
|
||||
std::fs::rename(&tmp, &abs)
|
||||
.map_err(|e| CatalogError::Io(format!("renaming {}: {e}", abs.display())))?;
|
||||
|
||||
// `pinned` is OR-ed rather than assigned: an image that was already
|
||||
// pinned must not be demoted to evictable because it happened to be
|
||||
// opened in develop, which is a passive store.
|
||||
conn.execute(
|
||||
"INSERT INTO image_cache
|
||||
(image_id, tier_actual, tier_desired, bytes, last_used, pinned, path)
|
||||
VALUES (?1, ?2, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(image_id) DO UPDATE SET
|
||||
tier_actual = ?2,
|
||||
tier_desired = max(tier_desired, ?2),
|
||||
bytes = ?3,
|
||||
last_used = ?4,
|
||||
pinned = max(pinned, ?5),
|
||||
path = ?6",
|
||||
rusqlite::params![
|
||||
image.0 as i64,
|
||||
Tier::Original.stored(),
|
||||
bytes.len() as i64,
|
||||
now,
|
||||
i64::from(pinned),
|
||||
rel,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a cached original back, if it is here.
|
||||
///
|
||||
/// Touches `last_used`, which is what makes the eviction order reflect
|
||||
/// actual use rather than download order. A read that finds the row but
|
||||
/// not the file repairs the catalog rather than returning bytes it does
|
||||
/// not have — the two can diverge if a user clears the directory by hand.
|
||||
pub fn load(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
image: ImageId,
|
||||
now: i64,
|
||||
) -> Result<Option<Vec<u8>>, CatalogError> {
|
||||
let path: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT path FROM image_cache
|
||||
WHERE image_id = ?1 AND tier_actual >= ?2",
|
||||
rusqlite::params![image.0 as i64, Tier::Original.stored()],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let Some(rel) = path else { return Ok(None) };
|
||||
let abs = self.dir.join(&rel);
|
||||
|
||||
match std::fs::read(&abs) {
|
||||
Ok(bytes) => {
|
||||
conn.execute(
|
||||
"UPDATE image_cache SET last_used = ?2 WHERE image_id = ?1",
|
||||
rusqlite::params![image.0 as i64, now],
|
||||
)?;
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
Err(e) => {
|
||||
// The file is gone but the row says it is here. Believing the
|
||||
// row would report the image as locally available for ever
|
||||
// while every open failed.
|
||||
log::debug!(
|
||||
"cached original {} missing, forgetting it: {e}",
|
||||
abs.display()
|
||||
);
|
||||
self.forget(conn, &[image])?;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an image's original is on this device.
|
||||
pub fn holds_original(&self, conn: &Connection, image: ImageId) -> bool {
|
||||
conn.query_row(
|
||||
"SELECT 1 FROM image_cache
|
||||
WHERE image_id = ?1 AND tier_actual >= ?2",
|
||||
rusqlite::params![image.0 as i64, Tier::Original.stored()],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Mark images as pinned, so they are kept regardless of the budget.
|
||||
///
|
||||
/// Pinning records the *intent* — `tier_desired` — without downloading
|
||||
/// anything: the download needs a network, which belongs to the caller.
|
||||
/// An image already cached passively becomes pinned in place, keeping its
|
||||
/// bytes rather than re-fetching them.
|
||||
pub fn pin(&self, conn: &Connection, images: &[ImageId]) -> Result<usize, CatalogError> {
|
||||
self.set_pinned(conn, images, true)
|
||||
}
|
||||
|
||||
/// Release a pin, returning those images to the evictable population.
|
||||
///
|
||||
/// The bytes stay until eviction needs the room. Deleting immediately
|
||||
/// would make unpinning destructive, when it is meant only to withdraw a
|
||||
/// guarantee.
|
||||
pub fn unpin(&self, conn: &Connection, images: &[ImageId]) -> Result<usize, CatalogError> {
|
||||
self.set_pinned(conn, images, false)
|
||||
}
|
||||
|
||||
/// Release the pin *and* delete the bytes it was holding.
|
||||
///
|
||||
/// The destructive half of the pair [`unpin`](Self::unpin) deliberately is
|
||||
/// not. Unpinning answers "stop promising"; this answers "give me the disk
|
||||
/// back", which is the question actually being asked when a trip is over
|
||||
/// and the device is full. Leaving those gigabytes to sit until some future
|
||||
/// eviction happens to want the room is not an answer to it.
|
||||
///
|
||||
/// Nothing is lost that cannot be fetched again: the original lives on the
|
||||
/// server, and the catalog row, the ratings and the edit graph are all
|
||||
/// untouched here — they are authoritative and small (FR-NC-6b).
|
||||
///
|
||||
/// Returns how many images were released and how many bytes that freed.
|
||||
/// A file that has already vanished frees nothing and is still counted as
|
||||
/// released, because the row describing it goes either way.
|
||||
pub fn release(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
images: &[ImageId],
|
||||
) -> Result<(usize, u64), CatalogError> {
|
||||
if images.is_empty() {
|
||||
return Ok((0, 0));
|
||||
}
|
||||
|
||||
// Read the paths before the rows are rewritten: `forget` clears `path`,
|
||||
// and a file whose name has been forgotten cannot be deleted.
|
||||
let mut held = Vec::new();
|
||||
{
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT bytes, path FROM image_cache
|
||||
WHERE image_id = ?1 AND path IS NOT NULL",
|
||||
)?;
|
||||
for image in images {
|
||||
if let Some(row) = stmt
|
||||
.query_row(rusqlite::params![image.0 as i64], |r| {
|
||||
Ok((r.get::<_, i64>(0)? as u64, r.get::<_, String>(1)?))
|
||||
})
|
||||
.optional()?
|
||||
{
|
||||
held.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut freed = 0u64;
|
||||
for (bytes, rel) in &held {
|
||||
let abs = self.dir.join(rel);
|
||||
match std::fs::remove_file(&abs) {
|
||||
Ok(()) => freed += bytes,
|
||||
// Already gone is the ordinary case after a crash mid-write,
|
||||
// not a failure: the row still has to go, or the cache accounts
|
||||
// for space nothing occupies.
|
||||
Err(e) => log::debug!("releasing {}: {e}", abs.display()),
|
||||
}
|
||||
}
|
||||
|
||||
// Unpin first, then forget. The other order would leave a pinned row
|
||||
// claiming an original it no longer has, which `pending_pins` would
|
||||
// then dutifully download again — the exact opposite of what was asked.
|
||||
self.set_pinned(conn, images, false)?;
|
||||
self.forget(conn, images)?;
|
||||
|
||||
Ok((images.len(), freed))
|
||||
}
|
||||
|
||||
fn set_pinned(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
images: &[ImageId],
|
||||
pinned: bool,
|
||||
) -> Result<usize, CatalogError> {
|
||||
if images.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut n = 0;
|
||||
for image in images {
|
||||
n += tx.execute(
|
||||
"INSERT INTO image_cache (image_id, tier_actual, tier_desired, bytes, pinned)
|
||||
VALUES (?1, ?2, ?3, 0, ?4)
|
||||
ON CONFLICT(image_id) DO UPDATE SET
|
||||
pinned = ?4,
|
||||
-- A pin raises the target; releasing one lowers it back to
|
||||
-- whatever is actually held, so a released image is not
|
||||
-- left permanently claiming it wants an original.
|
||||
tier_desired = CASE WHEN ?4 = 1 THEN ?3 ELSE tier_actual END",
|
||||
rusqlite::params![
|
||||
image.0 as i64,
|
||||
Tier::Metadata.stored(),
|
||||
Tier::Original.stored(),
|
||||
i64::from(pinned),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Images a pin wants but which are not yet downloaded.
|
||||
///
|
||||
/// The work list for whatever fetches originals. Ordered by id for a
|
||||
/// stable, resumable sequence rather than an arbitrary one.
|
||||
pub fn pending_pins(&self, conn: &Connection) -> Result<Vec<ImageId>, CatalogError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT image_id FROM image_cache
|
||||
WHERE pinned = 1 AND tier_actual < tier_desired
|
||||
ORDER BY image_id",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// How full the cache is, split by population.
|
||||
///
|
||||
/// Counts only rows that actually hold an original: a pin that has not
|
||||
/// downloaded yet occupies no disk, and counting its intent would evict
|
||||
/// real files to make room for bytes that do not exist.
|
||||
pub fn usage(&self, conn: &Connection) -> Result<Usage, CatalogError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT pinned, count(*), coalesce(sum(bytes), 0)
|
||||
FROM image_cache
|
||||
WHERE tier_actual >= ?1
|
||||
GROUP BY pinned",
|
||||
)?;
|
||||
let mut usage = Usage::default();
|
||||
let rows = stmt.query_map(rusqlite::params![Tier::Original.stored()], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (pinned, count, bytes) = row?;
|
||||
if pinned == 1 {
|
||||
usage.pinned_count = count as usize;
|
||||
usage.pinned_bytes = bytes as u64;
|
||||
} else {
|
||||
usage.passive_count = count as usize;
|
||||
usage.passive_bytes = bytes as u64;
|
||||
}
|
||||
}
|
||||
Ok(usage)
|
||||
}
|
||||
|
||||
/// Evict least-recently-used passive entries until the budget is met.
|
||||
///
|
||||
/// Returns how many images were dropped. Pinned entries are never
|
||||
/// candidates, which is the guarantee that makes a pin worth making.
|
||||
///
|
||||
/// A row whose file has already vanished is still dropped from the
|
||||
/// catalog: it frees no disk, but leaving it would let a phantom entry
|
||||
/// hold the cache permanently over budget and evict real files in its
|
||||
/// place.
|
||||
pub fn enforce(&self, conn: &Connection) -> Result<usize, CatalogError> {
|
||||
let usage = self.usage(conn)?;
|
||||
let mut over = self.budget.overage(usage.passive_bytes);
|
||||
if over == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Oldest first. `last_used IS NULL` sorts first deliberately: a row
|
||||
// that has never been read back is the least valuable thing here.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT image_id, bytes, path FROM image_cache
|
||||
WHERE pinned = 0 AND tier_actual >= ?1
|
||||
ORDER BY last_used IS NULL DESC, last_used ASC",
|
||||
)?;
|
||||
let candidates = stmt
|
||||
.query_map(rusqlite::params![Tier::Original.stored()], |r| {
|
||||
Ok((
|
||||
ImageId(r.get::<_, i64>(0)? as u64),
|
||||
r.get::<_, i64>(1)? as u64,
|
||||
r.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let mut evicted = Vec::new();
|
||||
for (image, bytes, path) in candidates {
|
||||
if over == 0 {
|
||||
break;
|
||||
}
|
||||
if let Some(rel) = path {
|
||||
let abs = self.dir.join(rel);
|
||||
if let Err(e) = std::fs::remove_file(&abs) {
|
||||
// Already gone is the common case and not a failure; the
|
||||
// row still has to go, or it accounts for space nothing
|
||||
// occupies.
|
||||
log::debug!("evicting {}: {e}", abs.display());
|
||||
}
|
||||
}
|
||||
over = over.saturating_sub(bytes);
|
||||
evicted.push(image);
|
||||
}
|
||||
|
||||
let n = evicted.len();
|
||||
self.forget(conn, &evicted)?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Drop cache rows, without touching files.
|
||||
///
|
||||
/// The row is reduced to `Metadata` rather than deleted, so a pin recorded
|
||||
/// against it survives: unpinning is the only thing that should clear a
|
||||
/// pin, and eviction of the bytes is not unpinning.
|
||||
fn forget(&self, conn: &Connection, images: &[ImageId]) -> Result<(), CatalogError> {
|
||||
if images.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for image in images {
|
||||
tx.execute(
|
||||
"UPDATE image_cache
|
||||
SET tier_actual = ?2, bytes = 0, path = NULL
|
||||
WHERE image_id = ?1",
|
||||
rusqlite::params![image.0 as i64, Tier::Metadata.stored()],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Everything currently held, newest use first. For a cache management view.
|
||||
pub fn entries(&self, conn: &Connection) -> Result<Vec<Entry>, CatalogError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT image_id, tier_actual, tier_desired, bytes, last_used, pinned, path
|
||||
FROM image_cache
|
||||
WHERE tier_actual >= ?1
|
||||
ORDER BY last_used IS NULL, last_used DESC",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![Tier::Original.stored()], |r| {
|
||||
Ok(Entry {
|
||||
image: ImageId(r.get::<_, i64>(0)? as u64),
|
||||
tier: Tier::from_stored(r.get(1)?),
|
||||
desired: Tier::from_stored(r.get(2)?),
|
||||
bytes: r.get::<_, i64>(3)? as u64,
|
||||
last_used: r.get(4)?,
|
||||
pinned: r.get::<_, i64>(5)? == 1,
|
||||
path: r.get(6)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Catalog;
|
||||
|
||||
/// Distinguishes concurrent fixtures. The harness runs tests in parallel,
|
||||
/// and a shared directory would have one test's eviction delete another's
|
||||
/// files.
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// A scratch directory that is fresh for each call.
|
||||
fn tempdir() -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"dr-cache-test-{}-{}",
|
||||
std::process::id(),
|
||||
SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
base
|
||||
}
|
||||
|
||||
/// A catalog with `n` images, and a cache in a scratch directory.
|
||||
fn fixture(n: usize) -> (Catalog, Cache, PathBuf, Vec<ImageId>) {
|
||||
fixture_with(n, Budget::bytes(1000))
|
||||
}
|
||||
|
||||
fn fixture_with(n: usize, budget: Budget) -> (Catalog, Cache, PathBuf, Vec<ImageId>) {
|
||||
let catalog = Catalog::in_memory().unwrap();
|
||||
catalog
|
||||
.connection()
|
||||
.execute(
|
||||
"INSERT INTO roots (id, kind, label) VALUES (1, 'remote', 'test')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..n {
|
||||
catalog
|
||||
.connection()
|
||||
.execute(
|
||||
"INSERT INTO images (root_id, source_ref, added_at)
|
||||
VALUES (1, ?1, 0)",
|
||||
rusqlite::params![format!("Photos/img{i:03}.CR2")],
|
||||
)
|
||||
.unwrap();
|
||||
ids.push(ImageId(catalog.connection().last_insert_rowid() as u64));
|
||||
}
|
||||
let dir = tempdir();
|
||||
let cache = Cache::open(&dir, budget).unwrap();
|
||||
(catalog, cache, dir, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_original_reads_back() {
|
||||
let (cat, cache, _dir, ids) = fixture(1);
|
||||
cache
|
||||
.store(cat.connection(), ids[0], "a.CR2", b"raw bytes", false, 10)
|
||||
.unwrap();
|
||||
|
||||
assert!(cache.holds_original(cat.connection(), ids[0]));
|
||||
assert_eq!(
|
||||
cache.load(cat.connection(), ids[0], 20).unwrap().as_deref(),
|
||||
Some(&b"raw bytes"[..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_never_stored_is_absent() {
|
||||
let (cat, cache, _dir, ids) = fixture(1);
|
||||
assert!(!cache.holds_original(cat.connection(), ids[0]));
|
||||
assert_eq!(cache.load(cat.connection(), ids[0], 0).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_takes_the_least_recently_used_first() {
|
||||
let (cat, cache, _dir, ids) = fixture(3);
|
||||
// 400 each against a 1000 budget: storing the third puts it 200 over.
|
||||
let bytes = vec![0u8; 400];
|
||||
cache
|
||||
.store(cat.connection(), ids[0], "a.CR2", &bytes, false, 10)
|
||||
.unwrap();
|
||||
cache
|
||||
.store(cat.connection(), ids[1], "b.CR2", &bytes, false, 20)
|
||||
.unwrap();
|
||||
cache
|
||||
.store(cat.connection(), ids[2], "c.CR2", &bytes, false, 30)
|
||||
.unwrap();
|
||||
|
||||
// Touch the oldest so it is no longer the least recently used.
|
||||
cache.load(cat.connection(), ids[0], 40).unwrap();
|
||||
|
||||
assert_eq!(cache.enforce(cat.connection()).unwrap(), 1);
|
||||
// ids[1] was the stalest by the time eviction ran.
|
||||
assert!(!cache.holds_original(cat.connection(), ids[1]));
|
||||
assert!(cache.holds_original(cat.connection(), ids[0]));
|
||||
assert!(cache.holds_original(cat.connection(), ids[2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pinned_original_is_never_evicted() {
|
||||
// The guarantee the whole feature rests on: a pinned trip must still
|
||||
// be there after a day of browsing pushes the cache over its cap.
|
||||
let (cat, cache, _dir, ids) = fixture(3);
|
||||
let bytes = vec![0u8; 800];
|
||||
|
||||
cache
|
||||
.store(cat.connection(), ids[0], "a.CR2", &bytes, true, 10)
|
||||
.unwrap();
|
||||
cache
|
||||
.store(cat.connection(), ids[1], "b.CR2", &bytes, false, 20)
|
||||
.unwrap();
|
||||
cache
|
||||
.store(cat.connection(), ids[2], "c.CR2", &bytes, false, 30)
|
||||
.unwrap();
|
||||
|
||||
cache.enforce(cat.connection()).unwrap();
|
||||
|
||||
assert!(
|
||||
cache.holds_original(cat.connection(), ids[0]),
|
||||
"the pinned original survives even though it is the oldest"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_a_pin_frees_the_disk_it_was_holding() {
|
||||
// What "remove the local copies" has to mean. Unpinning alone leaves
|
||||
// the bytes for a future eviction to notice, which is no answer at all
|
||||
// to a device that is full now.
|
||||
let (cat, cache, dir, ids) = fixture(2);
|
||||
let bytes = vec![0u8; 700];
|
||||
cache
|
||||
.store(cat.connection(), ids[0], "a.CR2", &bytes, true, 10)
|
||||
.unwrap();
|
||||
cache
|
||||
.store(cat.connection(), ids[1], "b.CR2", &bytes, true, 20)
|
||||
.unwrap();
|
||||
|
||||
let (released, freed) = cache.release(cat.connection(), &ids).unwrap();
|
||||
assert_eq!(released, 2);
|
||||
assert_eq!(freed, 1400);
|
||||
|
||||
assert!(!cache.holds_original(cat.connection(), ids[0]));
|
||||
assert_eq!(cache.usage(cat.connection()).unwrap().pinned_bytes, 0);
|
||||
|
||||
// The files themselves, not just the bookkeeping: a row cleared over a
|
||||
// file still on disk is how a cache comes to hold gigabytes it does not
|
||||
// know about.
|
||||
let left: Vec<_> = walk_files(&dir);
|
||||
assert!(left.is_empty(), "files remain on disk: {left:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_released_pin_is_not_downloaded_all_over_again() {
|
||||
// The failure mode of releasing in the wrong order: bytes deleted while
|
||||
// the row still says an original is wanted, so the next pin fetch pulls
|
||||
// the whole trip back down.
|
||||
let (cat, cache, _dir, ids) = fixture(1);
|
||||
cache
|
||||
.store(cat.connection(), ids[0], "a.CR2", &[0u8; 100], true, 10)
|
||||
.unwrap();
|
||||
|
||||
cache.release(cat.connection(), &ids).unwrap();
|
||||
|
||||
assert!(cache.pending_pins(cat.connection()).unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Every file under `dir`, for asserting that a release left nothing.
|
||||
fn walk_files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return out;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
out.extend(walk_files(&path));
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_bytes_do_not_count_against_the_budget() {
|
||||
// Otherwise a large pin starves the passive cache into evicting
|
||||
// everything, and browsing becomes uncacheable the moment a trip is
|
||||
// pinned.
|
||||
let (cat, cache, _dir, ids) = fixture(2);
|
||||
cache
|
||||
.store(
|
||||
cat.connection(),
|
||||
ids[0],
|
||||
"a.CR2",
|
||||
&vec![0u8; 5000],
|
||||
true,
|
||||
10,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.store(
|
||||
cat.connection(),
|
||||
ids[1],
|
||||
"b.CR2",
|
||||
&vec![0u8; 500],
|
||||
false,
|
||||
20,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Pinned use is far past the 1000 budget, but the passive 500 fits.
|
||||
assert_eq!(cache.enforce(cat.connection()).unwrap(), 0);
|
||||
assert!(cache.holds_original(cat.connection(), ids[1]));
|
||||
|
||||
let usage = cache.usage(cat.connection()).unwrap();
|
||||
assert_eq!(usage.pinned_bytes, 5000);
|
||||
assert_eq!(usage.passive_bytes, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlimited_budget_evicts_nothing() {
|
||||
let (cat, cache, _dir, ids) = fixture_with(2, Budget::unlimited());
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
cache
|
||||
.store(
|
||||
cat.connection(),
|
||||
*id,
|
||||
"a.CR2",
|
||||
&vec![0u8; 100_000],
|
||||
false,
|
||||
i as i64,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(cache.enforce(cat.connection()).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinning_records_intent_without_bytes() {
|
||||
// A pin is not a download: it says what should be here, and something
|
||||
// with a network makes it so.
|
||||
let (cat, cache, _dir, ids) = fixture(2);
|
||||
cache.pin(cat.connection(), &ids).unwrap();
|
||||
|
||||
assert!(!cache.holds_original(cat.connection(), ids[0]));
|
||||
assert_eq!(cache.pending_pins(cat.connection()).unwrap(), ids);
|
||||
assert_eq!(cache.usage(cat.connection()).unwrap().pinned_bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_downloaded_pin_stops_being_pending() {
|
||||
let (cat, cache, _dir, ids) = fixture(2);
|
||||
cache.pin(cat.connection(), &ids).unwrap();
|
||||
cache
|
||||
.store(cat.connection(), ids[0], "a.CR2", b"bytes", true, 10)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(cache.pending_pins(cat.connection()).unwrap(), vec![ids[1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinning_an_already_cached_image_keeps_its_bytes() {
|
||||
// Re-downloading something already on disk because the user pinned it
|
||||
// would be the most visible possible waste.
|
||||
let (cat, cache, _dir, ids) = fixture(1);
|
||||
cache
|
||||
.store(cat.connection(), ids[0], "a.CR2", b"raw bytes", false, 10)
|
||||
.unwrap();
|
||||
cache.pin(cat.connection(), &ids).unwrap();
|
||||
|
||||
assert!(cache.pending_pins(cat.connection()).unwrap().is_empty());
|
||||
assert_eq!(
|
||||
cache.load(cat.connection(), ids[0], 20).unwrap().as_deref(),
|
||||
Some(&b"raw bytes"[..])
|
||||
);
|
||||
assert_eq!(cache.usage(cat.connection()).unwrap().pinned_bytes, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_pinned_image_does_not_unpin_it() {
|
||||
// The develop path stores passively. If that overwrote `pinned`, then
|
||||
// simply *looking at* a pinned photograph would silently make it
|
||||
// evictable — the pin would decay through use.
|
||||
let (cat, cache, _dir, ids) = fixture(1);
|
||||
cache.pin(cat.connection(), &ids).unwrap();
|
||||
cache
|
||||
.store(cat.connection(), ids[0], "a.CR2", b"bytes", false, 10)
|
||||
.unwrap();
|
||||
|
||||
let entries = cache.entries(cat.connection()).unwrap();
|
||||
assert!(entries[0].pinned, "still pinned after a passive store");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpinning_keeps_the_bytes_but_makes_them_evictable() {
|
||||
let (cat, cache, _dir, ids) = fixture(2);
|
||||
cache
|
||||
.store(cat.connection(), ids[0], "a.CR2", &vec![0u8; 800], true, 10)
|
||||
.unwrap();
|
||||
cache.unpin(cat.connection(), &ids[0..1]).unwrap();
|
||||
|
||||
// Still here — unpinning withdraws a guarantee, it does not delete.
|
||||
assert!(cache.holds_original(cat.connection(), ids[0]));
|
||||
|
||||
// But now it is a candidate.
|
||||
cache
|
||||
.store(
|
||||
cat.connection(),
|
||||
ids[1],
|
||||
"b.CR2",
|
||||
&vec![0u8; 800],
|
||||
false,
|
||||
20,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cache.enforce(cat.connection()).unwrap(), 1);
|
||||
assert!(!cache.holds_original(cat.connection(), ids[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_is_forgotten_rather_than_reported_present() {
|
||||
// A user clearing the cache directory by hand must not leave every
|
||||
// image claiming to be local while every open fails.
|
||||
let (cat, cache, dir, ids) = fixture_with(1, Budget::bytes(1000));
|
||||
cache
|
||||
.store(cat.connection(), ids[0], "a.CR2", b"bytes", false, 10)
|
||||
.unwrap();
|
||||
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
std::fs::remove_file(entry.unwrap().path()).unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(cache.load(cat.connection(), ids[0], 20).unwrap(), None);
|
||||
assert!(!cache.holds_original(cat.connection(), ids[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storing_the_same_image_twice_is_one_entry() {
|
||||
let (cat, cache, _dir, ids) = fixture(1);
|
||||
cache
|
||||
.store(cat.connection(), ids[0], "a.CR2", b"first", false, 10)
|
||||
.unwrap();
|
||||
cache
|
||||
.store(cat.connection(), ids[0], "a.CR2", b"second try", false, 20)
|
||||
.unwrap();
|
||||
|
||||
let usage = cache.usage(cat.connection()).unwrap();
|
||||
assert_eq!(usage.passive_count, 1);
|
||||
assert_eq!(usage.passive_bytes, 10, "the later size, not the sum");
|
||||
assert_eq!(
|
||||
cache.load(cat.connection(), ids[0], 30).unwrap().as_deref(),
|
||||
Some(&b"second try"[..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_images_with_the_same_filename_do_not_collide() {
|
||||
// `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different
|
||||
// photographs; a cache keyed on the filename would serve one for the
|
||||
// other, which is the worst failure this cache could have.
|
||||
let (cat, cache, _dir, ids) = fixture(2);
|
||||
cache
|
||||
.store(
|
||||
cat.connection(),
|
||||
ids[0],
|
||||
"Photos/IMG_0001.CR2",
|
||||
b"first",
|
||||
false,
|
||||
10,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.store(
|
||||
cat.connection(),
|
||||
ids[1],
|
||||
"Trips/IMG_0001.CR2",
|
||||
b"second",
|
||||
false,
|
||||
20,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cache.load(cat.connection(), ids[0], 30).unwrap().as_deref(),
|
||||
Some(&b"first"[..])
|
||||
);
|
||||
assert_eq!(
|
||||
cache.load(cat.connection(), ids[1], 30).unwrap().as_deref(),
|
||||
Some(&b"second"[..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_stops_once_the_budget_is_met() {
|
||||
// Evicting everything on a small overage would throw away a working
|
||||
// set to reclaim a few bytes.
|
||||
let (cat, cache, _dir, ids) = fixture(3);
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
cache
|
||||
.store(
|
||||
cat.connection(),
|
||||
*id,
|
||||
"a.CR2",
|
||||
&vec![0u8; 400],
|
||||
false,
|
||||
i as i64,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// 1200 held against 1000: dropping one 400-byte entry suffices.
|
||||
assert_eq!(cache.enforce(cat.connection()).unwrap(), 1);
|
||||
assert_eq!(cache.usage(cat.connection()).unwrap().passive_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extensionless_source_still_gets_a_path() {
|
||||
let (cat, cache, _dir, ids) = fixture(1);
|
||||
cache
|
||||
.store(
|
||||
cat.connection(),
|
||||
ids[0],
|
||||
"Photos/no-extension",
|
||||
b"bytes",
|
||||
false,
|
||||
10,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cache.load(cat.connection(), ids[0], 20).unwrap().as_deref(),
|
||||
Some(&b"bytes"[..])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! TRACES: FR-CAT-11
|
||||
//! Has this photograph been imported before?
|
||||
//!
|
||||
//! Two tiers, because neither alone is enough and they cost very different
|
||||
//! amounts. The metadata tier — capture time, camera, size, the name the
|
||||
//! camera gave it — is answerable from the catalog before a byte leaves the
|
||||
//! card, which is what makes re-inserting an already-imported card cost a
|
||||
//! metadata read per file rather than a full transfer. The content tier
|
||||
//! catches what the first misses: the same frame arriving under a different
|
||||
//! name, from a second card, or after somebody renamed it.
|
||||
//!
|
||||
//! # Why the filename is compared here rather than in SQL
|
||||
//!
|
||||
//! `images.source_ref` holds the whole opaque key — a relative path on Linux,
|
||||
//! a document id on SAF — and the camera's filename is only its last
|
||||
//! component. Matching that in SQL means `LIKE '%/IMG_0001.CR3'`, which cannot
|
||||
//! use an index, scans the whole table, and is wrong on SAF where the
|
||||
//! separator is not `/`. So the query narrows on the indexed columns and the
|
||||
//! handful of rows that survive are compared in Rust, the same way the grid
|
||||
//! already derives a display name.
|
||||
//!
|
||||
//! # Filename alone is never sufficient
|
||||
//!
|
||||
//! Camera filenames wrap at `IMG_9999` and start again, so a library of any
|
||||
//! age holds several unrelated `IMG_0001.CR3`. That is why the cheap tier
|
||||
//! carries capture time and camera as well, and why the expensive tier exists
|
||||
//! at all.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::CatalogError;
|
||||
|
||||
/// The last component of a stored source reference.
|
||||
///
|
||||
/// Splits on both separators for the same reason `Catalog::window` does: the
|
||||
/// key's shape belongs to the storage that produced it, and a SAF document id
|
||||
/// is delimited with `:`.
|
||||
fn file_name(source_ref: &str) -> &str {
|
||||
source_ref.rsplit(['/', ':']).next().unwrap_or(source_ref)
|
||||
}
|
||||
|
||||
/// Whether the catalog already holds this photograph, on metadata alone.
|
||||
///
|
||||
/// `camera` is the joined make-and-model string the scan stores, not the raw
|
||||
/// EXIF pair — the caller composes it the same way, or the comparison is
|
||||
/// always false.
|
||||
///
|
||||
/// A `captured_at` of `None` makes this answer `false` rather than matching
|
||||
/// every undated image in the library: without a capture time the key is
|
||||
/// filename plus size, which two frames from the same body collide on
|
||||
/// routinely. An undated file falls through to the content tier, which is
|
||||
/// slower and right.
|
||||
pub fn seen_by_metadata(
|
||||
conn: &Connection,
|
||||
captured_at: Option<i64>,
|
||||
camera: Option<&str>,
|
||||
size: u64,
|
||||
original_name: &str,
|
||||
) -> Result<bool, CatalogError> {
|
||||
let Some(captured_at) = captured_at else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
// `images_captured` indexes the capture time, so this reads a few rows
|
||||
// even in a library of fifty thousand: one instant to the second holds
|
||||
// one frame, or a handful on a body shooting a burst.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT source_ref FROM images
|
||||
WHERE captured_at = ?1
|
||||
AND (?2 IS NULL OR camera IS ?2)
|
||||
AND (file_size IS NULL OR file_size = ?3)",
|
||||
)?;
|
||||
let mut rows = stmt.query(rusqlite::params![captured_at, camera, size as i64])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let source_ref: String = row.get(0)?;
|
||||
if file_name(&source_ref).eq_ignore_ascii_case(original_name) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Whether these exact bytes are already in the library.
|
||||
///
|
||||
/// The tier that costs a read of the file. Cheap here — `images_hash` is a
|
||||
/// partial index over the rows that have one — and expensive for the caller,
|
||||
/// which had to hash something to ask.
|
||||
pub fn seen_by_content(conn: &Connection, digest: &str) -> Result<bool, CatalogError> {
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM images WHERE content_hash = ?1",
|
||||
[digest],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Record the digest of a file the import computed.
|
||||
///
|
||||
/// An import reads every byte anyway, so the hash is free at that moment and
|
||||
/// costs a full read of an 80 MB file at any other. Storing it is what lets
|
||||
/// the *next* import answer [`seen_by_content`] without reading anything.
|
||||
///
|
||||
/// Matched on `source_ref` within a root, which is how the scan that just
|
||||
/// catalogued the imported file identifies it. Returns how many rows were
|
||||
/// updated: zero means the scan has not reached the file yet, which is a
|
||||
/// normal race and not an error.
|
||||
pub fn set_content_hash(
|
||||
conn: &Connection,
|
||||
root_id: u64,
|
||||
source_ref: &str,
|
||||
digest: &str,
|
||||
) -> Result<usize, CatalogError> {
|
||||
Ok(conn.execute(
|
||||
"UPDATE images SET content_hash = ?3
|
||||
WHERE root_id = ?1 AND source_ref = ?2",
|
||||
rusqlite::params![root_id as i64, source_ref, digest],
|
||||
)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Catalog;
|
||||
|
||||
/// A catalog holding one photograph, as a scan plus a metadata pass would
|
||||
/// leave it.
|
||||
fn with_one() -> Catalog {
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, source_ref, captured_at, camera, file_size,
|
||||
content_hash, added_at)
|
||||
VALUES (1, '2026/2026-08-22/IMG_0001.CR3', 1787407200, 'Canon EOS R5',
|
||||
9, 'deadbeef', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
cat
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_inserting_the_same_card_is_recognised_before_a_transfer() {
|
||||
let cat = with_one();
|
||||
assert!(seen_by_metadata(
|
||||
cat.connection(),
|
||||
Some(1_787_407_200),
|
||||
Some("Canon EOS R5"),
|
||||
9,
|
||||
"IMG_0001.CR3"
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_frame_at_the_same_instant_is_not_a_duplicate() {
|
||||
// Two bodies firing together, or a burst. The name separates them.
|
||||
let cat = with_one();
|
||||
assert!(!seen_by_metadata(
|
||||
cat.connection(),
|
||||
Some(1_787_407_200),
|
||||
Some("Canon EOS R5"),
|
||||
9,
|
||||
"IMG_0002.CR3"
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_name_from_a_different_camera_is_not_a_duplicate() {
|
||||
// IMG_0001.CR3 exists on every card ever formatted.
|
||||
let cat = with_one();
|
||||
assert!(!seen_by_metadata(
|
||||
cat.connection(),
|
||||
Some(1_787_407_200),
|
||||
Some("NIKON Z 9"),
|
||||
9,
|
||||
"IMG_0001.CR3"
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_name_at_a_different_time_is_not_a_duplicate() {
|
||||
// The IMG_9999 wrap: the library holds an unrelated IMG_0001.CR3 from
|
||||
// four years ago, and matching on name alone would refuse to import
|
||||
// today's.
|
||||
let cat = with_one();
|
||||
assert!(!seen_by_metadata(
|
||||
cat.connection(),
|
||||
Some(1_600_000_000),
|
||||
Some("Canon EOS R5"),
|
||||
9,
|
||||
"IMG_0001.CR3"
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undated_file_falls_through_to_the_content_tier() {
|
||||
// Not "matches everything undated" — that would silently refuse to
|
||||
// import a whole card of scanned film.
|
||||
let cat = with_one();
|
||||
assert!(!seen_by_metadata(cat.connection(), None, None, 9, "IMG_0001.CR3").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_grew_is_not_the_one_already_held() {
|
||||
// A truncated earlier import, or a different rendition of the same
|
||||
// frame. Same instant, same camera, same name, different bytes.
|
||||
let cat = with_one();
|
||||
assert!(!seen_by_metadata(
|
||||
cat.connection(),
|
||||
Some(1_787_407_200),
|
||||
Some("Canon EOS R5"),
|
||||
1234,
|
||||
"IMG_0001.CR3"
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_with_no_recorded_size_still_matches() {
|
||||
// The scan stores a size, but a row merged from another device may
|
||||
// not have one, and refusing to match it would re-import the library.
|
||||
let cat = with_one();
|
||||
cat.connection()
|
||||
.execute("UPDATE images SET file_size = NULL", [])
|
||||
.unwrap();
|
||||
assert!(seen_by_metadata(
|
||||
cat.connection(),
|
||||
Some(1_787_407_200),
|
||||
Some("Canon EOS R5"),
|
||||
9,
|
||||
"IMG_0001.CR3"
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_frame_renamed_is_caught_by_its_bytes() {
|
||||
let cat = with_one();
|
||||
// The metadata tier misses it...
|
||||
assert!(!seen_by_metadata(
|
||||
cat.connection(),
|
||||
Some(1_787_407_200),
|
||||
Some("Canon EOS R5"),
|
||||
9,
|
||||
"holiday-42.CR3"
|
||||
)
|
||||
.unwrap());
|
||||
// ...and the content tier does not.
|
||||
assert!(seen_by_content(cat.connection(), "deadbeef").unwrap());
|
||||
assert!(!seen_by_content(cat.connection(), "cafe").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_digest_recorded_now_answers_the_next_import() {
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, source_ref, added_at)
|
||||
VALUES (1, '2026/2026-08-22/IMG_0001.CR3', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!seen_by_content(c, "abc123").unwrap());
|
||||
let n = set_content_hash(c, 1, "2026/2026-08-22/IMG_0001.CR3", "abc123").unwrap();
|
||||
assert_eq!(n, 1);
|
||||
assert!(seen_by_content(c, "abc123").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_a_digest_before_the_scan_arrives_is_not_an_error() {
|
||||
// The import writes the file and the scan catalogues it; between those
|
||||
// two moments there is no row to update, and that is a race rather
|
||||
// than a failure.
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
set_content_hash(c, 1, "not/scanned/yet.CR3", "abc").unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_is_the_last_component_of_either_kind_of_key() {
|
||||
assert_eq!(file_name("2026/2026-08-22/IMG_0001.CR3"), "IMG_0001.CR3");
|
||||
// A SAF document id delimits with a colon.
|
||||
assert_eq!(file_name("primary:DCIM/Camera/IMG_1.CR3"), "IMG_1.CR3");
|
||||
assert_eq!(file_name("IMG_0001.CR3"), "IMG_0001.CR3");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! TRACES: NFR-ARCH-4 | NFR-R5
|
||||
//! Catalog errors.
|
||||
//!
|
||||
//! Typed and attached to the affected subject rather than panicking — a
|
||||
//! corrupt row or a failed job marks one image and lets the batch continue.
|
||||
|
||||
/// Something went wrong talking to the catalog.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CatalogError {
|
||||
#[error("sqlite: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
|
||||
/// The catalog was written by a newer build.
|
||||
///
|
||||
/// Opening it read-write would corrupt state this build cannot represent,
|
||||
/// so the app refuses and says so (NFR-R5).
|
||||
#[error("catalog schema v{found} is newer than this build supports (v{supported})")]
|
||||
SchemaTooNew { found: i64, supported: i64 },
|
||||
|
||||
/// A scan could not reach a root at all.
|
||||
///
|
||||
/// Distinct from "files are missing": this aborts the scan *before* the
|
||||
/// deletion sweep, because every folder would look unreached and the sweep
|
||||
/// would delete the whole library (FR-CAT-9).
|
||||
#[error("root {0} is unreachable; scan aborted without pruning")]
|
||||
RootUnreachable(u64),
|
||||
|
||||
/// A scan was asked for a root the catalog has no row for.
|
||||
///
|
||||
/// A caller's mistake rather than a user's: the row is created when the
|
||||
/// grant is obtained, because the label — the path, the tree URI — is known
|
||||
/// only there. Inventing one here would file the library under a name
|
||||
/// nothing else would look it up by.
|
||||
#[error("no such root: {0}")]
|
||||
NoSuchRoot(u64),
|
||||
|
||||
/// A smart collection whose selector references itself, directly or via
|
||||
/// another collection.
|
||||
#[error("collection {0} would form a cycle")]
|
||||
CollectionCycle(u64),
|
||||
|
||||
#[error("no such collection: {0}")]
|
||||
NoSuchCollection(u64),
|
||||
|
||||
/// A keyword the caller named is gone — deleted, or fused into another by a
|
||||
/// merge while its id sat in a UI model.
|
||||
///
|
||||
/// Its own variant rather than a silent no-op because the two are different
|
||||
/// answers to the user: a rename that quietly did nothing looks exactly like
|
||||
/// a rename that did not take.
|
||||
#[error("no such keyword: {0}")]
|
||||
NoSuchKeyword(u64),
|
||||
|
||||
/// Images were dropped onto a smart collection.
|
||||
///
|
||||
/// A smart collection's membership *is* its selector, so member rows would
|
||||
/// be a second source of truth that nothing reads. Refused rather than
|
||||
/// silently discarded, so the UI can say why the drop did nothing.
|
||||
#[error("collection {0} is a saved filter; its contents cannot be edited by hand")]
|
||||
SmartCollectionNotEditable(u64),
|
||||
|
||||
#[error("malformed stored selector: {0}")]
|
||||
BadSelector(String),
|
||||
|
||||
/// A name the user typed that cannot be stored — blank, or one a sibling
|
||||
/// already holds.
|
||||
///
|
||||
/// Its own variant rather than a reused `BadSelector`, because this one is
|
||||
/// shown to the user verbatim: it has to read as a sentence about their
|
||||
/// collection, not as a diagnostic about a stored selector.
|
||||
#[error("{0}")]
|
||||
BadName(String),
|
||||
|
||||
#[error("io: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
//! TRACES: FR-CAT-3 | NFR-ARCH-2 | FR-PLAT-AND-3
|
||||
//! The background work queue.
|
||||
//!
|
||||
//! Jobs live in the catalog, so they survive process death — routine on
|
||||
//! Android rather than exceptional (FR-PLAT-AND-3). Two properties carry the
|
||||
//! design:
|
||||
//!
|
||||
//! - **Coalescing.** `UNIQUE(kind, subject_id)` makes enqueueing idempotent,
|
||||
//! so every code path that notices a change can just enqueue and let the
|
||||
//! table absorb the redundancy.
|
||||
//! - **Priority shared with the GPU scheduler** (ARCH §5.3), so one notion of
|
||||
//! urgency governs the whole app and visible work always preempts bulk work.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// What a job does.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i64)]
|
||||
pub enum JobKind {
|
||||
/// Recursive incremental scan from a folder (§scan).
|
||||
ScanFolder = 0,
|
||||
/// Promote an image from stat-only to full EXIF.
|
||||
ExtractMetadata = 1,
|
||||
/// Build or rebuild a thumbnail.
|
||||
Thumbnail = 2,
|
||||
/// A sidecar on disk is newer than what the catalog read.
|
||||
ReadSidecar = 3,
|
||||
/// Flush a local edit to its sidecar. Debounced, never per slider tick.
|
||||
WriteSidecar = 4,
|
||||
/// Whole-file hash. On demand only — import dedup, reconnect-by-hash.
|
||||
ContentHash = 5,
|
||||
/// Range-extract an embedded preview from a remote file (FR-NC-3).
|
||||
FetchPreview = 6,
|
||||
/// Fetch a full original: pinned by rule, or explicitly asked for.
|
||||
FetchOriginal = 7,
|
||||
}
|
||||
|
||||
impl JobKind {
|
||||
fn from_i64(v: i64) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => JobKind::ScanFolder,
|
||||
1 => JobKind::ExtractMetadata,
|
||||
2 => JobKind::Thumbnail,
|
||||
3 => JobKind::ReadSidecar,
|
||||
4 => JobKind::WriteSidecar,
|
||||
5 => JobKind::ContentHash,
|
||||
6 => JobKind::FetchPreview,
|
||||
7 => JobKind::FetchOriginal,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this job transfers over the network, and so is subject to the
|
||||
/// metered-connection and charging constraints in FR-NC-6.
|
||||
pub fn is_network(self) -> bool {
|
||||
matches!(self, JobKind::FetchPreview | JobKind::FetchOriginal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scheduling class, matching the GPU tile scheduler (ARCH §5.3).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[repr(i64)]
|
||||
pub enum Priority {
|
||||
/// Bulk work: metadata sweeps, rule-driven fetches, hashing.
|
||||
Background = 0,
|
||||
/// Just outside the viewport; the next image in culling.
|
||||
Prefetch = 1,
|
||||
/// Visible cells, and the image currently open.
|
||||
///
|
||||
/// Strictly preempts background work. Without this, scrolling during a
|
||||
/// bulk thumbnail pass misses its frame budget — the common case, not an
|
||||
/// edge case (NFR-ARCH-2).
|
||||
Interactive = 2,
|
||||
}
|
||||
|
||||
/// Lifecycle state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i64)]
|
||||
pub enum JobState {
|
||||
Pending = 0,
|
||||
Running = 1,
|
||||
Failed = 2,
|
||||
}
|
||||
|
||||
/// A job ready to run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Job {
|
||||
pub id: i64,
|
||||
pub kind: JobKind,
|
||||
pub subject_id: Option<i64>,
|
||||
pub priority: Priority,
|
||||
pub attempts: i64,
|
||||
pub payload: Option<String>,
|
||||
}
|
||||
|
||||
/// Give up after this many attempts and attach the error to the subject.
|
||||
///
|
||||
/// One corrupt file must not stall the queue behind endless retries
|
||||
/// (FR-RAW-4).
|
||||
pub const MAX_ATTEMPTS: i64 = 5;
|
||||
|
||||
/// Backoff before retrying a failed job, in seconds.
|
||||
///
|
||||
/// Exponential, capped — a server that is down for an hour should not be
|
||||
/// retried every second, and a transient decode failure should not wait an
|
||||
/// hour.
|
||||
pub fn backoff_seconds(attempts: i64) -> i64 {
|
||||
const CAP: i64 = 300;
|
||||
match attempts {
|
||||
a if a <= 0 => 0,
|
||||
a if a >= 9 => CAP,
|
||||
a => (1i64 << (a - 1)).min(CAP),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueue work, coalescing with any identical pending job.
|
||||
///
|
||||
/// Re-requesting at a higher priority *promotes* the existing row rather than
|
||||
/// duplicating it, which is what lets the grid shout "this one is visible now"
|
||||
/// about a job already queued in the background.
|
||||
pub fn enqueue(
|
||||
conn: &Connection,
|
||||
kind: JobKind,
|
||||
subject_id: Option<i64>,
|
||||
priority: Priority,
|
||||
payload: Option<&str>,
|
||||
) -> Result<(), CatalogError> {
|
||||
conn.execute(
|
||||
"INSERT INTO jobs(kind, subject_id, priority, state, payload)
|
||||
VALUES (?1, ?2, ?3, 0, ?4)
|
||||
ON CONFLICT(kind, subject_id) DO UPDATE SET
|
||||
priority = max(jobs.priority, excluded.priority),
|
||||
-- A job that failed and is being re-requested deserves a fresh
|
||||
-- start: the file may well have changed since it failed.
|
||||
state = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.state END,
|
||||
attempts = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.attempts END,
|
||||
not_before = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.not_before END",
|
||||
rusqlite::params![kind as i64, subject_id, priority as i64, payload],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Claim the next runnable job, highest priority first.
|
||||
///
|
||||
/// `now` is passed rather than read from the clock so backoff is testable.
|
||||
/// Claiming marks the row `Running` in the same transaction as the read, so
|
||||
/// two workers cannot take the same job.
|
||||
pub fn claim_next(conn: &Connection, now: i64) -> Result<Option<Job>, CatalogError> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
|
||||
let job = tx
|
||||
.query_row(
|
||||
"SELECT id, kind, subject_id, priority, attempts, payload
|
||||
FROM jobs
|
||||
WHERE state = 0 AND not_before <= ?1
|
||||
ORDER BY priority DESC, id ASC
|
||||
LIMIT 1",
|
||||
[now],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, Option<i64>>(2)?,
|
||||
r.get::<_, i64>(3)?,
|
||||
r.get::<_, i64>(4)?,
|
||||
r.get::<_, Option<String>>(5)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
|
||||
let Some((id, kind, subject_id, priority, attempts, payload)) = job else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
tx.execute(
|
||||
"UPDATE jobs SET state = 1, attempts = attempts + 1 WHERE id = ?1",
|
||||
[id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
|
||||
Ok(Some(Job {
|
||||
id,
|
||||
kind: JobKind::from_i64(kind).unwrap_or(JobKind::ExtractMetadata),
|
||||
subject_id,
|
||||
priority: match priority {
|
||||
2 => Priority::Interactive,
|
||||
1 => Priority::Prefetch,
|
||||
_ => Priority::Background,
|
||||
},
|
||||
attempts: attempts + 1,
|
||||
payload,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Job finished successfully.
|
||||
pub fn complete(conn: &Connection, id: i64) -> Result<(), CatalogError> {
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Job failed. Reschedules with backoff, or gives up past [`MAX_ATTEMPTS`].
|
||||
pub fn fail(conn: &Connection, job: &Job, now: i64, err: &str) -> Result<(), CatalogError> {
|
||||
if job.attempts >= MAX_ATTEMPTS {
|
||||
conn.execute(
|
||||
"UPDATE jobs SET state = 2, last_error = ?2 WHERE id = ?1",
|
||||
rusqlite::params![job.id, err],
|
||||
)?;
|
||||
} else {
|
||||
conn.execute(
|
||||
"UPDATE jobs SET state = 0, not_before = ?2, last_error = ?3 WHERE id = ?1",
|
||||
rusqlite::params![job.id, now + backoff_seconds(job.attempts), err],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recover jobs orphaned by process death.
|
||||
///
|
||||
/// A row left `Running` has no owner — the process that claimed it is gone.
|
||||
/// Called at startup, before any worker begins (FR-PLAT-AND-3).
|
||||
pub fn recover_orphaned(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
let n = conn.execute("UPDATE jobs SET state = 0 WHERE state = 1", [])?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::schema;
|
||||
|
||||
fn db() -> Connection {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
schema::configure(&c).unwrap();
|
||||
schema::migrate(&c).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_enqueue_coalesces() {
|
||||
let c = db();
|
||||
for _ in 0..10 {
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
}
|
||||
let n: i64 = c
|
||||
.query_row("SELECT count(*) FROM jobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_enqueueing_at_higher_priority_promotes() {
|
||||
let c = db();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
// The grid scrolls this image into view.
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Interactive, None).unwrap();
|
||||
|
||||
let p: i64 = c
|
||||
.query_row("SELECT priority FROM jobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(p, Priority::Interactive as i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_never_regresses() {
|
||||
let c = db();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Interactive, None).unwrap();
|
||||
// A background sweep must not demote work the user is waiting on.
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
|
||||
let p: i64 = c
|
||||
.query_row("SELECT priority FROM jobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(p, Priority::Interactive as i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_takes_highest_priority_first() {
|
||||
let c = db();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(2), Priority::Interactive, None).unwrap();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(3), Priority::Prefetch, None).unwrap();
|
||||
|
||||
let first = claim_next(&c, 0).unwrap().unwrap();
|
||||
assert_eq!(first.subject_id, Some(2));
|
||||
let second = claim_next(&c, 0).unwrap().unwrap();
|
||||
assert_eq!(second.subject_id, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_claimed_job_is_not_claimed_twice() {
|
||||
let c = db();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
assert!(claim_next(&c, 0).unwrap().is_some());
|
||||
assert!(claim_next(&c, 0).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_backs_off_then_becomes_claimable_again() {
|
||||
let c = db();
|
||||
enqueue(
|
||||
&c,
|
||||
JobKind::FetchPreview,
|
||||
Some(1),
|
||||
Priority::Background,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let job = claim_next(&c, 100).unwrap().unwrap();
|
||||
fail(&c, &job, 100, "network down").unwrap();
|
||||
|
||||
// Still backing off.
|
||||
assert!(claim_next(&c, 100).unwrap().is_none());
|
||||
// Past the backoff.
|
||||
assert!(claim_next(&c, 100 + backoff_seconds(job.attempts))
|
||||
.unwrap()
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_persistently_failing_job_stops_retrying() {
|
||||
let c = db();
|
||||
enqueue(
|
||||
&c,
|
||||
JobKind::ExtractMetadata,
|
||||
Some(1),
|
||||
Priority::Background,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut now = 0;
|
||||
for _ in 0..MAX_ATTEMPTS {
|
||||
let job = claim_next(&c, now).unwrap().expect("should be claimable");
|
||||
fail(&c, &job, now, "corrupt file").unwrap();
|
||||
now += backoff_seconds(job.attempts);
|
||||
}
|
||||
|
||||
// One corrupt file must not stall the queue forever (FR-RAW-4).
|
||||
assert!(claim_next(&c, now + 100_000).unwrap().is_none());
|
||||
let state: i64 = c
|
||||
.query_row("SELECT state FROM jobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(state, JobState::Failed as i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_requesting_a_failed_job_gives_it_a_fresh_start() {
|
||||
let c = db();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
let mut now = 0;
|
||||
for _ in 0..MAX_ATTEMPTS {
|
||||
let job = claim_next(&c, now).unwrap().unwrap();
|
||||
fail(&c, &job, now, "boom").unwrap();
|
||||
now += backoff_seconds(job.attempts);
|
||||
}
|
||||
// The file changed on disk, so the old failure says nothing about it.
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Interactive, None).unwrap();
|
||||
let job = claim_next(&c, now).unwrap().expect("retryable again");
|
||||
assert_eq!(job.attempts, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphaned_jobs_return_to_pending_on_restart() {
|
||||
let c = db();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
claim_next(&c, 0).unwrap().unwrap();
|
||||
// Process dies here. Android does this routinely.
|
||||
assert_eq!(recover_orphaned(&c).unwrap(), 1);
|
||||
assert!(claim_next(&c, 0).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_grows_then_caps() {
|
||||
assert_eq!(backoff_seconds(0), 0);
|
||||
assert_eq!(backoff_seconds(1), 1);
|
||||
assert_eq!(backoff_seconds(3), 4);
|
||||
assert_eq!(backoff_seconds(100), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_jobs_are_identifiable_for_metered_gating() {
|
||||
// FR-NC-6: transfers respect unmetered-network and charging
|
||||
// constraints; local work must not be gated by them.
|
||||
assert!(JobKind::FetchOriginal.is_network());
|
||||
assert!(JobKind::FetchPreview.is_network());
|
||||
assert!(!JobKind::Thumbnail.is_network());
|
||||
assert!(!JobKind::ExtractMetadata.is_network());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
//! TRACES: FR-CAT-2 | FR-CAT-4 | FR-CAT-6 | NFR-P1
|
||||
//! The catalog: a rebuildable index over the library.
|
||||
//!
|
||||
//! Not a source of truth. Sidecars next to the images hold the authoritative
|
||||
//! edit state (ARCH §6.12), and this file is deletable at any time — rebuilt
|
||||
//! by rescanning sources and reading sidecars. That inversion is deliberate:
|
||||
//! darktable maintains both a database and sidecars while achieving the
|
||||
//! reliability of neither.
|
||||
//!
|
||||
//! # What lives here
|
||||
//!
|
||||
//! - [`schema`] — tables and forward-only migrations
|
||||
//! - [`scan`] — incremental discovery that prunes unchanged directories
|
||||
//! - [`walk`] — those decisions driven against real storage, local or SAF
|
||||
//! - [`query`] — selectors compiled to indexed SQL, windowed for the grid
|
||||
//! - [`collections`] — the collection tree and membership the UI edits
|
||||
//! - [`keywords`] — the keyword vocabulary and what it is assigned to
|
||||
//! - [`jobs`] — the durable background work queue
|
||||
//! - [`trash`] — soft delete to a folder, then permanent delete
|
||||
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
|
||||
//!
|
||||
//! # The one thing everything is designed around
|
||||
//!
|
||||
//! **Work is proportional to what changed, or to what the user is looking at —
|
||||
//! never to library size.** A 50k-image library that has not changed costs one
|
||||
//! metadata probe per folder to verify (§scan), no thumbnails to regenerate
|
||||
//! (§jobs coalescing), and no rule evaluation per grid cell (materialised
|
||||
//! `tier_desired`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use dr_types::{Availability, ImageId};
|
||||
use rusqlite::Connection;
|
||||
|
||||
pub mod cache;
|
||||
pub mod collections;
|
||||
pub mod dedup;
|
||||
pub mod error;
|
||||
pub mod jobs;
|
||||
pub mod keywords;
|
||||
pub mod merge;
|
||||
pub mod query;
|
||||
pub mod rating;
|
||||
pub mod scan;
|
||||
pub mod schema;
|
||||
pub mod sync;
|
||||
pub mod trash;
|
||||
pub mod walk;
|
||||
|
||||
pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
|
||||
pub use collections::{Collection, CollectionKind, TreeRow};
|
||||
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
|
||||
pub use error::CatalogError;
|
||||
pub use jobs::{Job, JobKind, Priority};
|
||||
pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword};
|
||||
pub use merge::MergeReport;
|
||||
pub use query::{Query, Sort};
|
||||
pub use rating::{Judgement, MAX_RATING};
|
||||
pub use scan::{DirAction, DirState, EntryAction, ScanOutcome};
|
||||
pub use trash::{TrashedImage, TRASH_DIR};
|
||||
pub use walk::{ensure_root, scan_root, RootKind, ScanProgress, ScanReport};
|
||||
|
||||
/// One row of the library grid.
|
||||
///
|
||||
/// Exactly what a cell draws and nothing more — no join per cell, and
|
||||
/// availability reads a materialised column rather than evaluating cache rules
|
||||
/// (ARCH §9.5).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GridRow {
|
||||
pub id: ImageId,
|
||||
pub name: String,
|
||||
pub availability: Availability,
|
||||
/// UTC seconds. `None` until EXIF has been read.
|
||||
pub captured_at: Option<i64>,
|
||||
/// Minutes east of UTC, for rendering the photographer's local time.
|
||||
pub captured_offset: Option<i32>,
|
||||
/// 0 = nothing, 1 = stat-only, 2 = full EXIF.
|
||||
pub metadata_state: u8,
|
||||
}
|
||||
|
||||
/// A count of images in one time bucket, for the timeline scrubber.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TimeBucket {
|
||||
/// UTC seconds at the bucket's start.
|
||||
pub start: i64,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
/// Time bucket size.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Granularity {
|
||||
Year,
|
||||
Month,
|
||||
Day,
|
||||
Hour,
|
||||
}
|
||||
|
||||
impl Granularity {
|
||||
/// SQLite `strftime` format that collapses a timestamp to this bucket.
|
||||
///
|
||||
/// Applied to **local** time, not UTC: "everything from 3 August" means
|
||||
/// the photographer's 3 August, which is why `captured_offset` is stored
|
||||
/// alongside the UTC timestamp.
|
||||
/// Public so a caller that must build its own bucketing query — one
|
||||
/// joining collection membership, say — buckets identically to
|
||||
/// [`Catalog::timeline_range`] rather than reimplementing the format.
|
||||
pub fn strftime(self) -> &'static str {
|
||||
match self {
|
||||
Granularity::Year => "%Y",
|
||||
Granularity::Month => "%Y-%m",
|
||||
Granularity::Day => "%Y-%m-%d",
|
||||
Granularity::Hour => "%Y-%m-%dT%H",
|
||||
}
|
||||
}
|
||||
|
||||
/// A sensible bucket size for a span of seconds, so the UI need not guess.
|
||||
pub fn for_span(seconds: i64) -> Self {
|
||||
const DAY: i64 = 86_400;
|
||||
match seconds {
|
||||
s if s > 5 * 365 * DAY => Granularity::Year,
|
||||
s if s > 90 * DAY => Granularity::Month,
|
||||
s if s > 2 * DAY => Granularity::Day,
|
||||
_ => Granularity::Hour,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection to the catalog.
|
||||
pub struct Catalog {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// Open or create a catalog, migrating it forward if needed.
|
||||
pub fn open(path: &Path) -> Result<Self, CatalogError> {
|
||||
let conn = Connection::open(path)?;
|
||||
schema::configure(&conn)?;
|
||||
let from = schema::migrate(&conn)?;
|
||||
// A migration adds a column; it cannot know what the value should be
|
||||
// for rows that already existed. Backfilling on open is what stops
|
||||
// those rows being silently partial.
|
||||
for (what, n) in schema::backfill(&conn)? {
|
||||
log::info!("backfilled {what} for {n} row(s) (schema was v{from})");
|
||||
}
|
||||
Ok(Catalog { conn })
|
||||
}
|
||||
|
||||
/// An in-memory catalog, for tests and for a throwaway import preview.
|
||||
pub fn in_memory() -> Result<Self, CatalogError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
schema::configure(&conn)?;
|
||||
schema::migrate(&conn)?;
|
||||
schema::backfill(&conn)?;
|
||||
Ok(Catalog { conn })
|
||||
}
|
||||
|
||||
/// Escape hatch for modules that need raw access. Not part of the UI-facing
|
||||
/// surface.
|
||||
pub fn connection(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
|
||||
/// How many images match.
|
||||
///
|
||||
/// Returned alongside the first window so the grid can size its scrollbar
|
||||
/// and paint in one round trip.
|
||||
pub fn count(&self, q: &Query, now: i64) -> Result<usize, CatalogError> {
|
||||
let c = query::compile(&q.filter, now);
|
||||
let sql = query::count_sql(&c);
|
||||
let n: i64 =
|
||||
self.conn
|
||||
.query_row(&sql, rusqlite::params_from_iter(c.params.iter()), |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
/// Fetch one window of results.
|
||||
///
|
||||
/// Never returns the whole catalog: FR-CAT-4 requires memory bounded
|
||||
/// independently of library size.
|
||||
pub fn window(
|
||||
&self,
|
||||
q: &Query,
|
||||
range: std::ops::Range<usize>,
|
||||
now: i64,
|
||||
) -> Result<Vec<GridRow>, CatalogError> {
|
||||
let c = query::compile(&q.filter, now);
|
||||
let sql = query::window_sql(q, &c);
|
||||
|
||||
let mut params = c.params.clone();
|
||||
params.push(rusqlite::types::Value::Integer(range.len() as i64));
|
||||
params.push(rusqlite::types::Value::Integer(range.start as i64));
|
||||
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
let source_ref: String = r.get(1)?;
|
||||
let avail: i64 = r.get(2)?;
|
||||
Ok(GridRow {
|
||||
id: ImageId(r.get::<_, i64>(0)? as u64),
|
||||
name: source_ref
|
||||
.rsplit(['/', ':'])
|
||||
.next()
|
||||
.unwrap_or(&source_ref)
|
||||
.to_string(),
|
||||
availability: decode_availability(avail),
|
||||
captured_at: r.get(3)?,
|
||||
captured_offset: r.get::<_, Option<i64>>(4)?.map(|v| v as i32),
|
||||
metadata_state: r.get::<_, i64>(5)? as u8,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Counts per time bucket, for the timeline scrubber.
|
||||
///
|
||||
/// One grouped aggregate over the `images_captured` index — not 50k rows
|
||||
/// handed to the UI to bucket itself.
|
||||
pub fn timeline(
|
||||
&self,
|
||||
q: &Query,
|
||||
g: Granularity,
|
||||
now: i64,
|
||||
) -> Result<Vec<TimeBucket>, CatalogError> {
|
||||
let c = query::compile(&q.filter, now);
|
||||
// Bucketed in local time: captured_offset is minutes east of UTC, and
|
||||
// NULL falls back to UTC rather than dropping the row.
|
||||
let sql = format!(
|
||||
"SELECT min(captured_at) AS start,
|
||||
count(*) AS n
|
||||
FROM images
|
||||
-- A shadowed JPEG is the same frame as its RAW; counting both
|
||||
-- would double every paired shot in the histogram.
|
||||
WHERE {} AND captured_at IS NOT NULL AND shadowed_by IS NULL
|
||||
GROUP BY strftime('{}', captured_at + coalesce(captured_offset, 0) * 60,
|
||||
'unixepoch')
|
||||
ORDER BY start ASC",
|
||||
c.where_sql,
|
||||
g.strftime()
|
||||
);
|
||||
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(c.params.iter()), |r| {
|
||||
Ok(TimeBucket {
|
||||
start: r.get(0)?,
|
||||
count: r.get::<_, i64>(1)? as u32,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Counts per time bucket, bounded to a date range.
|
||||
///
|
||||
/// What a zoomed timeline needs: [`timeline`](Self::timeline) always spans
|
||||
/// the whole library, so zooming in would return the same coarse buckets
|
||||
/// with the ends cropped rather than finer detail over a narrower span.
|
||||
pub fn timeline_range(
|
||||
&self,
|
||||
q: &Query,
|
||||
g: Granularity,
|
||||
from: i64,
|
||||
to: i64,
|
||||
now: i64,
|
||||
) -> Result<Vec<TimeBucket>, CatalogError> {
|
||||
let c = query::compile(&q.filter, now);
|
||||
let sql = format!(
|
||||
"SELECT min(captured_at) AS start,
|
||||
count(*) AS n
|
||||
FROM images
|
||||
WHERE {} AND captured_at IS NOT NULL AND shadowed_by IS NULL
|
||||
AND captured_at >= ?{} AND captured_at <= ?{}
|
||||
GROUP BY strftime('{}', captured_at + coalesce(captured_offset, 0) * 60,
|
||||
'unixepoch')
|
||||
ORDER BY start ASC",
|
||||
c.where_sql,
|
||||
c.params.len() + 1,
|
||||
c.params.len() + 2,
|
||||
g.strftime()
|
||||
);
|
||||
|
||||
let mut params = c.params.clone();
|
||||
params.push(rusqlite::types::Value::Integer(from));
|
||||
params.push(rusqlite::types::Value::Integer(to));
|
||||
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok(TimeBucket {
|
||||
start: r.get(0)?,
|
||||
count: r.get::<_, i64>(1)? as u32,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Merge a downloaded remote catalog's collections into this one.
|
||||
///
|
||||
/// See [`sync`] for why only collections cross over.
|
||||
pub fn merge_remote_catalog(&self, remote: &Path) -> Result<MergeReport, CatalogError> {
|
||||
sync::merge_remote(&self.conn, remote)
|
||||
}
|
||||
|
||||
/// Write a consistent snapshot ready to upload.
|
||||
pub fn snapshot_for_upload(&self, dest: &Path) -> Result<(), CatalogError> {
|
||||
sync::snapshot_for_upload(&self.conn, dest)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_availability(v: i64) -> Availability {
|
||||
match v {
|
||||
1 => Availability::Preview,
|
||||
2 => Availability::Original,
|
||||
3 => Availability::Offline,
|
||||
_ => Availability::MetadataOnly,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_types::Selector;
|
||||
|
||||
fn seeded() -> Catalog {
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Three images across two days, one with no EXIF read yet.
|
||||
for (id, name, captured, state) in [
|
||||
(1i64, "a.CR3", Some(1_000_000i64), 2i64),
|
||||
(2, "b.CR3", Some(1_100_000), 2),
|
||||
(3, "c.CR3", None, 1),
|
||||
] {
|
||||
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();
|
||||
}
|
||||
cat
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_and_window_agree() {
|
||||
let cat = seeded();
|
||||
let q = Query::default();
|
||||
assert_eq!(cat.count(&q, 0).unwrap(), 3);
|
||||
assert_eq!(cat.window(&q, 0..10, 0).unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_is_bounded_by_the_requested_range() {
|
||||
// FR-CAT-4: memory independent of catalog size.
|
||||
let cat = seeded();
|
||||
let rows = cat.window(&Query::default(), 0..2, 0).unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paging_covers_every_row_exactly_once() {
|
||||
let cat = seeded();
|
||||
let q = Query::default();
|
||||
let mut seen = Vec::new();
|
||||
for start in (0..3).step_by(2) {
|
||||
seen.extend(cat.window(&q, start..start + 2, 0).unwrap());
|
||||
}
|
||||
let mut ids: Vec<u64> = seen.iter().map(|r| r.id.0).collect();
|
||||
ids.sort_unstable();
|
||||
assert_eq!(ids, vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_without_capture_time_sorts_last_not_first() {
|
||||
// Otherwise a freshly scanned library leads with whatever has not been
|
||||
// read yet, which looks like corruption to the user.
|
||||
let cat = seeded();
|
||||
let rows = cat.window(&Query::default(), 0..10, 0).unwrap();
|
||||
assert_eq!(rows.last().unwrap().id, ImageId(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_state_reaches_the_grid() {
|
||||
// The grid needs it to distinguish "no photos on this date" from
|
||||
// "EXIF not read yet" (FR-NC-6c's honesty principle).
|
||||
let cat = seeded();
|
||||
let rows = cat.window(&Query::default(), 0..10, 0).unwrap();
|
||||
let pending = rows.iter().find(|r| r.id == ImageId(3)).unwrap();
|
||||
assert_eq!(pending.metadata_state, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_narrows_the_count() {
|
||||
let cat = seeded();
|
||||
let q = Query {
|
||||
filter: Selector::Text("a.CR3".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(cat.count(&q, 0).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_buckets_and_skips_unread_images() {
|
||||
let cat = seeded();
|
||||
let buckets = cat
|
||||
.timeline(&Query::default(), Granularity::Day, 0)
|
||||
.unwrap();
|
||||
// Two images with timestamps, one day apart in UTC; the third has no
|
||||
// capture time and cannot be placed on a timeline at all.
|
||||
let total: u32 = buckets.iter().map(|b| b.count).sum();
|
||||
assert_eq!(total, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_granularity_follows_the_span() {
|
||||
const DAY: i64 = 86_400;
|
||||
assert_eq!(Granularity::for_span(10 * 365 * DAY), Granularity::Year);
|
||||
assert_eq!(Granularity::for_span(120 * DAY), Granularity::Month);
|
||||
assert_eq!(Granularity::for_span(10 * DAY), Granularity::Day);
|
||||
assert_eq!(Granularity::for_span(3600), Granularity::Hour);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_derived_for_both_paths_and_saf_ids() {
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'saf', 'tree')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (1, 1, 'primary:DCIM/Camera/IMG_1.CR3', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let rows = cat.window(&Query::default(), 0..10, 0).unwrap();
|
||||
assert_eq!(rows[0].name, "IMG_1.CR3");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
//! TRACES: FR-CAT-4 | FR-CAT-6
|
||||
//! Compiling a [`Selector`] into indexed SQL, and windowing the result.
|
||||
//!
|
||||
//! The UI never assembles SQL — it hands over a [`Query`] and receives a
|
||||
//! window. Two properties matter:
|
||||
//!
|
||||
//! 1. **Nothing user-supplied is interpolated into SQL text.** Every value
|
||||
//! binds as a parameter; `LIKE` patterns have their wildcards escaped.
|
||||
//! 2. **Predicates hit indices.** Filtering 50k images must stay interactive
|
||||
//! (FR-CAT-6), which means no expression over a column that would defeat
|
||||
//! its index.
|
||||
|
||||
use dr_types::{Availability, ColourLabel, DateSelector, FlagState, Selector};
|
||||
use rusqlite::types::Value;
|
||||
|
||||
/// What to show, and in what order.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Query {
|
||||
pub filter: Selector,
|
||||
pub sort: Sort,
|
||||
pub descending: bool,
|
||||
}
|
||||
|
||||
impl Default for Query {
|
||||
fn default() -> Self {
|
||||
Query {
|
||||
filter: Selector::All,
|
||||
sort: Sort::CapturedAt,
|
||||
descending: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Sort {
|
||||
CapturedAt,
|
||||
Added,
|
||||
FileName,
|
||||
Rating,
|
||||
/// Manual order within a collection. Falls back to capture time where the
|
||||
/// query is not scoped to one collection, since position is meaningless
|
||||
/// outside it.
|
||||
CollectionPosition,
|
||||
}
|
||||
|
||||
impl Sort {
|
||||
/// The ORDER BY fragment. Fixed strings — never user input.
|
||||
///
|
||||
/// Capture time sorts NULLs last regardless of direction: an image whose
|
||||
/// EXIF has not been read yet (metadata_state 1) should not lead the grid
|
||||
/// simply because its timestamp is unknown.
|
||||
fn sql(self, descending: bool) -> &'static str {
|
||||
match (self, descending) {
|
||||
(Sort::CapturedAt, false) => {
|
||||
"ORDER BY images.captured_at IS NULL, images.captured_at ASC, images.id ASC"
|
||||
}
|
||||
(Sort::CapturedAt, true) => {
|
||||
"ORDER BY images.captured_at IS NULL, images.captured_at DESC, images.id DESC"
|
||||
}
|
||||
(Sort::Added, false) => "ORDER BY images.added_at ASC, images.id ASC",
|
||||
(Sort::Added, true) => "ORDER BY images.added_at DESC, images.id DESC",
|
||||
(Sort::FileName, false) => "ORDER BY images.source_ref ASC, images.id ASC",
|
||||
(Sort::FileName, true) => "ORDER BY images.source_ref DESC, images.id DESC",
|
||||
(Sort::Rating, false) => "ORDER BY v.rating ASC, images.id ASC",
|
||||
(Sort::Rating, true) => "ORDER BY v.rating DESC, images.id DESC",
|
||||
(Sort::CollectionPosition, false) => {
|
||||
"ORDER BY cm.position IS NULL, cm.position ASC, images.captured_at ASC"
|
||||
}
|
||||
(Sort::CollectionPosition, true) => {
|
||||
"ORDER BY cm.position IS NULL, cm.position DESC, images.captured_at DESC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this sort needs the default-version join.
|
||||
fn needs_version(self) -> bool {
|
||||
matches!(self, Sort::Rating)
|
||||
}
|
||||
|
||||
/// Whether this sort needs a collection-membership join.
|
||||
fn needs_membership(self) -> bool {
|
||||
matches!(self, Sort::CollectionPosition)
|
||||
}
|
||||
}
|
||||
|
||||
/// A compiled WHERE clause plus its bound parameters.
|
||||
///
|
||||
/// Kept separate from the statement so `count` and `window` can share one
|
||||
/// compilation.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Compiled {
|
||||
pub where_sql: String,
|
||||
pub params: Vec<Value>,
|
||||
/// True if the filter depends on capture time, and therefore on EXIF that
|
||||
/// a freshly scanned library may not have read yet. The UI surfaces this
|
||||
/// rather than silently under-reporting.
|
||||
pub needs_capture_time: bool,
|
||||
}
|
||||
|
||||
/// Compile a selector to SQL against the `images` table.
|
||||
///
|
||||
/// `now` is passed rather than read from the clock so a rolling window is
|
||||
/// reproducible in tests and consistent across one query.
|
||||
pub fn compile(filter: &Selector, now: i64) -> Compiled {
|
||||
let mut params = Vec::new();
|
||||
let sql = if filter.is_unfiltered() {
|
||||
"1".to_string()
|
||||
} else {
|
||||
emit(filter, now, &mut params)
|
||||
};
|
||||
Compiled {
|
||||
where_sql: sql,
|
||||
params,
|
||||
needs_capture_time: filter.needs_capture_time(),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(s: &Selector, now: i64, p: &mut Vec<Value>) -> String {
|
||||
match s {
|
||||
Selector::All => "1".into(),
|
||||
|
||||
Selector::Collection(id) => {
|
||||
p.push(Value::Integer(id.0 as i64));
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM collection_members m
|
||||
WHERE m.image_id = images.id AND m.collection_id = ?{})",
|
||||
p.len()
|
||||
)
|
||||
}
|
||||
|
||||
Selector::Folder {
|
||||
root,
|
||||
path,
|
||||
recursive,
|
||||
} => {
|
||||
p.push(Value::Integer(root.0 as i64));
|
||||
let root_ix = p.len();
|
||||
if *recursive {
|
||||
// Prefix match on the folder path. `like_prefix` escapes the
|
||||
// pattern metacharacters, so a folder literally named "50%"
|
||||
// matches itself and not everything.
|
||||
p.push(Value::Text(like_prefix(path)));
|
||||
format!(
|
||||
"images.folder_id IN (
|
||||
SELECT id FROM folders
|
||||
WHERE root_id = ?{root_ix}
|
||||
AND (path = ?{p} OR path LIKE ?{p} || '/%' ESCAPE '\\'))",
|
||||
p = p.len()
|
||||
)
|
||||
} else {
|
||||
p.push(Value::Text(path.clone()));
|
||||
format!(
|
||||
"images.folder_id IN (
|
||||
SELECT id FROM folders WHERE root_id = ?{root_ix} AND path = ?{})",
|
||||
p.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Selector::DateRange(d) => emit_date(d, now, p),
|
||||
|
||||
Selector::Rating { min } => {
|
||||
p.push(Value::Integer(*min as i64));
|
||||
format!("{} >= ?{}", default_version_scalar("rating"), p.len())
|
||||
}
|
||||
|
||||
Selector::Label(l) => {
|
||||
p.push(Value::Integer(label_code(*l)));
|
||||
format!("{} = ?{}", default_version_scalar("label"), p.len())
|
||||
}
|
||||
|
||||
Selector::Flag(f) => {
|
||||
p.push(Value::Integer(flag_code(*f)));
|
||||
format!("{} = ?{}", default_version_scalar("flag"), p.len())
|
||||
}
|
||||
|
||||
Selector::Keyword(k) => {
|
||||
p.push(Value::Text(k.clone()));
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM keywords kw
|
||||
JOIN versions kv ON kv.id = kw.version_id
|
||||
WHERE kv.image_id = images.id AND kw.keyword = ?{})",
|
||||
p.len()
|
||||
)
|
||||
}
|
||||
|
||||
Selector::Camera(c) => {
|
||||
p.push(Value::Text(c.clone()));
|
||||
format!("images.camera = ?{}", p.len())
|
||||
}
|
||||
|
||||
Selector::Lens(l) => {
|
||||
p.push(Value::Text(l.clone()));
|
||||
format!("images.lens = ?{}", p.len())
|
||||
}
|
||||
|
||||
Selector::IsoRange { min, max } => {
|
||||
p.push(Value::Integer(*min as i64));
|
||||
let lo = p.len();
|
||||
p.push(Value::Integer(*max as i64));
|
||||
format!("images.iso BETWEEN ?{lo} AND ?{}", p.len())
|
||||
}
|
||||
|
||||
Selector::Availability(a) => {
|
||||
p.push(Value::Integer(availability_code(*a)));
|
||||
format!("images.availability = ?{}", p.len())
|
||||
}
|
||||
|
||||
Selector::Text(t) => {
|
||||
// Substring over filename and keywords. A LIKE scan is adequate at
|
||||
// 50k; if free text over title and description becomes a real
|
||||
// workflow, FTS5 is the answer and it is additive.
|
||||
p.push(Value::Text(format!("%{}%", escape_like(t))));
|
||||
let ix = p.len();
|
||||
format!(
|
||||
"(images.source_ref LIKE ?{ix} ESCAPE '\\'
|
||||
OR EXISTS (SELECT 1 FROM keywords kw
|
||||
JOIN versions kv ON kv.id = kw.version_id
|
||||
WHERE kv.image_id = images.id
|
||||
AND kw.keyword LIKE ?{ix} ESCAPE '\\'))"
|
||||
)
|
||||
}
|
||||
|
||||
// An empty conjunction is vacuously true; an empty disjunction matches
|
||||
// nothing. Both arise from a UI that lets every term be cleared, and
|
||||
// conflating them would show the whole library when the user meant the
|
||||
// opposite.
|
||||
Selector::All_(v) if v.is_empty() => "1".into(),
|
||||
Selector::Any(v) if v.is_empty() => "0".into(),
|
||||
|
||||
Selector::All_(v) => join(v, " AND ", now, p),
|
||||
Selector::Any(v) => join(v, " OR ", now, p),
|
||||
Selector::Not(inner) => format!("NOT ({})", emit(inner, now, p)),
|
||||
}
|
||||
}
|
||||
|
||||
fn join(items: &[Selector], op: &str, now: i64, p: &mut Vec<Value>) -> String {
|
||||
let parts: Vec<String> = items.iter().map(|s| emit(s, now, p)).collect();
|
||||
format!("({})", parts.join(op))
|
||||
}
|
||||
|
||||
fn emit_date(d: &DateSelector, now: i64, p: &mut Vec<Value>) -> String {
|
||||
match d {
|
||||
DateSelector::Between { from, to } => {
|
||||
p.push(Value::Integer(*from));
|
||||
let lo = p.len();
|
||||
p.push(Value::Integer(*to));
|
||||
// Half-open, so adjacent ranges neither overlap nor gap.
|
||||
format!(
|
||||
"(images.captured_at >= ?{lo} AND images.captured_at < ?{})",
|
||||
p.len()
|
||||
)
|
||||
}
|
||||
DateSelector::Rolling { days } => {
|
||||
let from = now - (*days as i64) * 86_400;
|
||||
p.push(Value::Integer(from));
|
||||
format!("images.captured_at >= ?{}", p.len())
|
||||
}
|
||||
DateSelector::CollectionSpan(id) => {
|
||||
p.push(Value::Integer(id.0 as i64));
|
||||
let ix = p.len();
|
||||
format!(
|
||||
"images.captured_at BETWEEN
|
||||
(SELECT min(i2.captured_at) FROM images i2
|
||||
JOIN collection_members m2 ON m2.image_id = i2.id
|
||||
WHERE m2.collection_id = ?{ix})
|
||||
AND (SELECT max(i2.captured_at) FROM images i2
|
||||
JOIN collection_members m2 ON m2.image_id = i2.id
|
||||
WHERE m2.collection_id = ?{ix})"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rating, label, and flag live on the *default* version, not the image.
|
||||
///
|
||||
/// A correlated subquery rather than a join, so these compose inside `OR` and
|
||||
/// `NOT` without the join multiplying rows.
|
||||
fn default_version_scalar(col: &str) -> String {
|
||||
format!(
|
||||
"(SELECT dv.{col} FROM versions dv
|
||||
WHERE dv.image_id = images.id AND dv.is_default = 1 LIMIT 1)"
|
||||
)
|
||||
}
|
||||
|
||||
/// Escape LIKE metacharacters so a literal `%` or `_` in user text matches
|
||||
/// itself. Paired with `ESCAPE '\'` in every LIKE that uses it.
|
||||
fn escape_like(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if matches!(c, '%' | '_' | '\\') {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn like_prefix(path: &str) -> String {
|
||||
escape_like(path.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
fn label_code(l: ColourLabel) -> i64 {
|
||||
match l {
|
||||
ColourLabel::Red => 1,
|
||||
ColourLabel::Yellow => 2,
|
||||
ColourLabel::Green => 3,
|
||||
ColourLabel::Blue => 4,
|
||||
ColourLabel::Purple => 5,
|
||||
}
|
||||
}
|
||||
|
||||
fn flag_code(f: FlagState) -> i64 {
|
||||
match f {
|
||||
FlagState::Unflagged => 0,
|
||||
FlagState::Pick => 1,
|
||||
FlagState::Reject => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored form of an availability. Shared with [`crate::walk`], which
|
||||
/// writes the column this reads — two spellings of the same mapping would
|
||||
/// filter for a state nothing ever writes.
|
||||
pub(crate) fn availability_code(a: Availability) -> i64 {
|
||||
match a {
|
||||
Availability::MetadataOnly => 0,
|
||||
Availability::Preview => 1,
|
||||
Availability::Original => 2,
|
||||
Availability::Offline => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the full SELECT for a window of results.
|
||||
///
|
||||
/// Joins are added only where the sort needs them, so an unsorted-by-rating
|
||||
/// grid query touches one table.
|
||||
pub fn window_sql(q: &Query, compiled: &Compiled) -> String {
|
||||
let mut joins = String::new();
|
||||
if q.sort.needs_version() {
|
||||
joins.push_str(" LEFT JOIN versions v ON v.image_id = images.id AND v.is_default = 1");
|
||||
}
|
||||
if q.sort.needs_membership() {
|
||||
// Only meaningful when the filter scopes to one collection; elsewhere
|
||||
// position is NULL and the sort falls through to capture time.
|
||||
joins.push_str(" LEFT JOIN collection_members cm ON cm.image_id = images.id");
|
||||
}
|
||||
format!(
|
||||
"SELECT images.id, images.source_ref, images.availability, images.captured_at, \
|
||||
images.captured_offset, images.metadata_state \
|
||||
FROM images{joins} WHERE {} {} LIMIT ? OFFSET ?",
|
||||
compiled.where_sql,
|
||||
q.sort.sql(q.descending)
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the COUNT for the same filter.
|
||||
pub fn count_sql(compiled: &Compiled) -> String {
|
||||
format!("SELECT count(*) FROM images WHERE {}", compiled.where_sql)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_types::{CollectionId, RootId};
|
||||
|
||||
#[test]
|
||||
fn unfiltered_compiles_to_a_constant() {
|
||||
let c = compile(&Selector::All, 0);
|
||||
assert_eq!(c.where_sql, "1");
|
||||
assert!(c.params.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_conjunction_and_disjunction_differ() {
|
||||
// The distinction that decides whether clearing a filter shows
|
||||
// everything or nothing.
|
||||
assert_eq!(compile(&Selector::All_(vec![]), 0).where_sql, "1");
|
||||
assert_eq!(compile(&Selector::Any(vec![]), 0).where_sql, "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_bind_rather_than_interpolate() {
|
||||
// The injection guard: a hostile keyword must appear in params, never
|
||||
// in SQL text.
|
||||
let evil = "'; DROP TABLE images; --";
|
||||
let c = compile(&Selector::Keyword(evil.into()), 0);
|
||||
assert!(!c.where_sql.contains("DROP"));
|
||||
assert_eq!(c.params, vec![Value::Text(evil.into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn like_metacharacters_are_escaped() {
|
||||
// A search for "50%" must not match everything containing "50".
|
||||
let c = compile(&Selector::Text("50%".into()), 0);
|
||||
assert_eq!(c.params, vec![Value::Text("%50\\%%".into())]);
|
||||
assert!(c.where_sql.contains("ESCAPE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backslash_in_search_text_is_itself_escaped() {
|
||||
let c = compile(&Selector::Text("a\\b".into()), 0);
|
||||
assert_eq!(c.params, vec![Value::Text("%a\\\\b%".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_window_resolves_against_supplied_now() {
|
||||
// Passed in rather than read from the clock, so the window is stable
|
||||
// across one query and reproducible in a test.
|
||||
let now = 1_000_000i64;
|
||||
let c = compile(
|
||||
&Selector::DateRange(DateSelector::Rolling { days: 90 }),
|
||||
now,
|
||||
);
|
||||
assert_eq!(c.params, vec![Value::Integer(now - 90 * 86_400)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn between_is_half_open() {
|
||||
let c = compile(
|
||||
&Selector::DateRange(DateSelector::Between { from: 10, to: 20 }),
|
||||
0,
|
||||
);
|
||||
// Half-open so adjacent day buckets neither overlap nor leave a gap.
|
||||
assert!(c.where_sql.contains(">= ?1"));
|
||||
assert!(c.where_sql.contains("< ?2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_composition_numbers_parameters_in_order() {
|
||||
let s = Selector::All_(vec![
|
||||
Selector::Rating { min: 4 },
|
||||
Selector::Any(vec![
|
||||
Selector::Camera("X-T5".into()),
|
||||
Selector::Not(Box::new(Selector::Lens("XF 35".into()))),
|
||||
]),
|
||||
]);
|
||||
let c = compile(&s, 0);
|
||||
assert_eq!(
|
||||
c.params,
|
||||
vec![
|
||||
Value::Integer(4),
|
||||
Value::Text("X-T5".into()),
|
||||
Value::Text("XF 35".into()),
|
||||
]
|
||||
);
|
||||
assert!(c.where_sql.contains("?1"));
|
||||
assert!(c.where_sql.contains("?2"));
|
||||
assert!(c.where_sql.contains("?3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_folder_matches_the_folder_itself_and_below() {
|
||||
let c = compile(
|
||||
&Selector::Folder {
|
||||
root: RootId(1),
|
||||
path: "2026/08".into(),
|
||||
recursive: true,
|
||||
},
|
||||
0,
|
||||
);
|
||||
// Both branches: the folder's own images and those in subfolders.
|
||||
assert!(c.where_sql.contains("path = ?2"));
|
||||
assert!(c.where_sql.contains("|| '/%'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_span_binds_its_id_once_and_reuses_it() {
|
||||
let c = compile(
|
||||
&Selector::DateRange(DateSelector::CollectionSpan(CollectionId(7))),
|
||||
0,
|
||||
);
|
||||
assert_eq!(c.params, vec![Value::Integer(7)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_time_dependency_is_reported() {
|
||||
let c = compile(&Selector::DateRange(DateSelector::Rolling { days: 7 }), 0);
|
||||
assert!(c.needs_capture_time);
|
||||
let c = compile(&Selector::Rating { min: 5 }, 0);
|
||||
assert!(!c.needs_capture_time);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_sort_puts_unknown_timestamps_last_in_both_directions() {
|
||||
// An image whose EXIF has not been read yet must not lead the grid
|
||||
// just because its timestamp is NULL.
|
||||
assert!(Sort::CapturedAt.sql(true).contains("IS NULL"));
|
||||
assert!(Sort::CapturedAt.sql(false).contains("IS NULL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_sql_joins_only_when_the_sort_needs_it() {
|
||||
let c = compile(&Selector::All, 0);
|
||||
let plain = window_sql(
|
||||
&Query {
|
||||
filter: Selector::All,
|
||||
sort: Sort::CapturedAt,
|
||||
descending: true,
|
||||
},
|
||||
&c,
|
||||
);
|
||||
assert!(!plain.contains("JOIN"));
|
||||
|
||||
let rated = window_sql(
|
||||
&Query {
|
||||
filter: Selector::All,
|
||||
sort: Sort::Rating,
|
||||
descending: true,
|
||||
},
|
||||
&c,
|
||||
);
|
||||
assert!(rated.contains("JOIN versions"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
//! TRACES: FR-CAT-5 | FR-CAT-6 | FR-CULL-4
|
||||
//! Star ratings and pick/reject flags — the judgement a cull produces.
|
||||
//!
|
||||
//! # Why this hangs off `versions` rather than `images`
|
||||
//!
|
||||
//! The schema already carries `rating`, `label` and `flag` on `versions`, and
|
||||
//! [`crate::query`] already compiles [`dr_types::Selector::Rating`] and
|
||||
//! [`dr_types::Selector::Flag`] against the *default* version. What was
|
||||
//! missing is that nothing ever created a version row: a scan inserts into
|
||||
//! `images` and stops, so every image had no version, and therefore nowhere
|
||||
//! to record a rating. The whole library sat permanently unrated with no way
|
||||
//! out of that state.
|
||||
//!
|
||||
//! So this module's first job is [`ensure_default_versions`] — every image
|
||||
//! gets exactly one default version, created at scan time and backfilled by
|
||||
//! the v2 migration for libraries scanned before this existed.
|
||||
//!
|
||||
//! Keeping judgement on the version rather than the image is what makes
|
||||
//! FR-CAT-12's virtual copies coherent: two crops of one frame are two
|
||||
//! photographs to the photographer, and one may be a keeper while the other
|
||||
//! is a reject. Hoisting the rating onto the image would force them to agree.
|
||||
//!
|
||||
//! # Unrated is a real state, not a zero
|
||||
//!
|
||||
//! `rating = 0` means *not yet judged*, and that is precisely what "filter to
|
||||
//! unjudged" selects (FR-CULL-4). It is deliberately not conflated with "one
|
||||
//! star" or with "rejected" — those are three different answers, and a cull
|
||||
//! that cannot distinguish "I have not looked at this" from "I looked and it
|
||||
//! is poor" cannot be resumed.
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
|
||||
use dr_types::{FlagState, ImageId};
|
||||
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// Highest star rating. Five, as every photo tool has settled on.
|
||||
pub const MAX_RATING: u8 = 5;
|
||||
|
||||
/// Name given to the version created for an image that has none.
|
||||
///
|
||||
/// Matches what [`crate::collections`] and the sidecar both expect to see for
|
||||
/// the original, unmodified frame.
|
||||
pub const DEFAULT_VERSION_NAME: &str = "Default";
|
||||
|
||||
/// The judgement recorded against one image's default version.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Judgement {
|
||||
/// 0..=5. Zero means *unrated*, which is a state in its own right.
|
||||
pub rating: u8,
|
||||
pub flag: FlagState,
|
||||
}
|
||||
|
||||
impl Judgement {
|
||||
/// Whether this image has been judged at all.
|
||||
///
|
||||
/// Either axis counts: a photographer who flags without starring, or stars
|
||||
/// without flagging, has still made a decision about the frame. "Filter to
|
||||
/// unjudged" (FR-CULL-4) is the negation of this, and getting it wrong
|
||||
/// means a resumed session re-presents work already done.
|
||||
pub fn is_judged(self) -> bool {
|
||||
self.rating > 0 || self.flag != FlagState::Unflagged
|
||||
}
|
||||
}
|
||||
|
||||
/// Give every image without one a default version.
|
||||
///
|
||||
/// Idempotent, and cheap on the common path: the `NOT EXISTS` sub-select is
|
||||
/// answered by the `versions_image` index, so a library that already has its
|
||||
/// versions costs one indexed scan and writes nothing.
|
||||
///
|
||||
/// Returns how many were created, so a scan can log the backfill rather than
|
||||
/// silently doing thousands of inserts.
|
||||
///
|
||||
/// The UUID is per row and generated here — it is the merge identity across
|
||||
/// devices (FR-NC-8), so two images must never share one.
|
||||
pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
// One transaction for the batch. A backfill over a 24k-image library is
|
||||
// 24k inserts, and per-statement commits would make it minutes rather
|
||||
// than seconds.
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let n = ensure_default_versions_within(&tx)?;
|
||||
tx.commit()?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// [`ensure_default_versions`] without opening a transaction.
|
||||
///
|
||||
/// Separate because SQLite has no nested `BEGIN`: [`crate::merge`] needs the
|
||||
/// invariant restored *inside* the merge transaction — an incoming keyword
|
||||
/// lands on a default version, so an image without one would silently drop it —
|
||||
/// and calling the public form there fails at runtime with "cannot start a
|
||||
/// transaction within a transaction". The same split, for the same reason, as
|
||||
/// `collections::add_within`.
|
||||
pub fn ensure_default_versions_within(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
let ids: Vec<i64> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT i.id FROM images i
|
||||
WHERE NOT EXISTS (SELECT 1 FROM versions v WHERE v.image_id = i.id)",
|
||||
)?;
|
||||
let found = stmt
|
||||
.query_map([], |r| r.get(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
found
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
{
|
||||
let mut insert = conn.prepare(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (?1, ?2, ?3, 1, 0, 0)",
|
||||
)?;
|
||||
for id in &ids {
|
||||
insert.execute(rusqlite::params![id, new_uuid(), DEFAULT_VERSION_NAME])?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ids.len())
|
||||
}
|
||||
|
||||
/// The default version's row id for an image, creating one if it has none.
|
||||
///
|
||||
/// Every write path goes through this rather than assuming a version exists.
|
||||
/// An image can arrive without one in two ways that are not worth trying to
|
||||
/// prevent: a row inserted by a build predating this module, and a scan whose
|
||||
/// version pass was interrupted between the image insert and the commit.
|
||||
/// Failing a rating because of either would be the wrong answer — the user
|
||||
/// pressed a key and expects a star.
|
||||
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM versions
|
||||
WHERE image_id = ?1
|
||||
ORDER BY is_default DESC, id ASC
|
||||
LIMIT 1",
|
||||
[image.0 as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some(id) = existing {
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (?1, ?2, ?3, 1, 0, 0)",
|
||||
rusqlite::params![image.0 as i64, new_uuid(), DEFAULT_VERSION_NAME],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Set the star rating for one image, clamped to 0..=[`MAX_RATING`].
|
||||
///
|
||||
/// Clamped rather than rejected: the value comes from a keystroke or a click
|
||||
/// on a star strip, and there is no useful error to show a photographer who
|
||||
/// pressed a key. Out of range can only mean a UI bug, and losing the
|
||||
/// keystroke would be a worse symptom than recording five.
|
||||
pub fn set_rating(conn: &Connection, image: ImageId, rating: u8) -> Result<(), CatalogError> {
|
||||
let version = default_version_id(conn, image)?;
|
||||
conn.execute(
|
||||
"UPDATE versions SET rating = ?2 WHERE id = ?1",
|
||||
rusqlite::params![version, rating.min(MAX_RATING) as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the pick/reject flag for one image.
|
||||
pub fn set_flag(conn: &Connection, image: ImageId, flag: FlagState) -> Result<(), CatalogError> {
|
||||
let version = default_version_id(conn, image)?;
|
||||
conn.execute(
|
||||
"UPDATE versions SET flag = ?2 WHERE id = ?1",
|
||||
rusqlite::params![version, flag_code(flag)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a rating to many images in one transaction.
|
||||
///
|
||||
/// The bulk path exists because rating a selection is one gesture: the user
|
||||
/// selects forty frames and presses `3`. Forty separate transactions would be
|
||||
/// forty fsyncs for what is conceptually a single edit, and a crash partway
|
||||
/// through would leave the selection half-rated.
|
||||
pub fn set_rating_many(
|
||||
conn: &Connection,
|
||||
images: &[ImageId],
|
||||
rating: u8,
|
||||
) -> Result<usize, CatalogError> {
|
||||
apply_many(conn, images, |conn, id| set_rating(conn, id, rating))
|
||||
}
|
||||
|
||||
/// Apply a flag to many images in one transaction. See [`set_rating_many`].
|
||||
pub fn set_flag_many(
|
||||
conn: &Connection,
|
||||
images: &[ImageId],
|
||||
flag: FlagState,
|
||||
) -> Result<usize, CatalogError> {
|
||||
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
|
||||
}
|
||||
|
||||
/// Shared bulk wrapper, so the two axes cannot drift in their commit
|
||||
/// behaviour — a partially-committed rating and a fully-committed flag from
|
||||
/// the same keystroke would be hard to explain and harder to notice.
|
||||
fn apply_many(
|
||||
conn: &Connection,
|
||||
images: &[ImageId],
|
||||
mut one: impl FnMut(&Connection, ImageId) -> Result<(), CatalogError>,
|
||||
) -> Result<usize, CatalogError> {
|
||||
if images.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for id in images {
|
||||
one(&tx, *id)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(images.len())
|
||||
}
|
||||
|
||||
/// Read the judgement for one image.
|
||||
///
|
||||
/// An image with no version reads as unrated and unflagged rather than as an
|
||||
/// error: that is exactly what it is.
|
||||
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
|
||||
let row: Option<(i64, i64)> = conn
|
||||
.query_row(
|
||||
"SELECT rating, flag FROM versions
|
||||
WHERE image_id = ?1
|
||||
ORDER BY is_default DESC, id ASC
|
||||
LIMIT 1",
|
||||
[image.0 as i64],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
Ok(match row {
|
||||
Some((rating, flag)) => Judgement {
|
||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||
flag: flag_from_code(flag),
|
||||
},
|
||||
None => Judgement::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Judgements for a window of images, in one statement.
|
||||
///
|
||||
/// The grid needs a star strip per cell, and one query per cell would be 120
|
||||
/// round trips on every scroll — the same reasoning as
|
||||
/// `collections_ui::sync_badges`. Images with no version simply do not appear
|
||||
/// in the result, and the caller treats a miss as unrated.
|
||||
pub fn judgements(
|
||||
conn: &Connection,
|
||||
images: &[ImageId],
|
||||
) -> Result<std::collections::HashMap<ImageId, Judgement>, CatalogError> {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
if images.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
// Placeholders are generated from the *count* of ids, never from any text
|
||||
// that came from outside — the same rule `read_cells_scoped` follows.
|
||||
let placeholders = std::iter::repeat_n("?", images.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!(
|
||||
"SELECT image_id, rating, flag FROM versions
|
||||
WHERE image_id IN ({placeholders}) AND is_default = 1"
|
||||
);
|
||||
|
||||
let params: Vec<rusqlite::types::Value> = images
|
||||
.iter()
|
||||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||||
.collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
for (image, rating, flag) in rows.flatten() {
|
||||
out.insert(
|
||||
ImageId(image as u64),
|
||||
Judgement {
|
||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||
flag: flag_from_code(flag),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How the library divides by rating, for the filter bar's counts.
|
||||
///
|
||||
/// Index `n` is the number of images rated `n`, so index 0 is the unrated
|
||||
/// count. Shown beside each filter button so the user can see there is
|
||||
/// something behind it before narrowing to it — a filter that silently
|
||||
/// empties the grid reads as a broken filter.
|
||||
pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
|
||||
let mut out = [0usize; 6];
|
||||
|
||||
// LEFT JOIN, so an image whose version row is missing still counts as
|
||||
// unrated rather than vanishing from the totals. The histogram has to sum
|
||||
// to the library size or it is not believable.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT coalesce(v.rating, 0) AS r, count(*)
|
||||
FROM images i
|
||||
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||||
GROUP BY r",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
|
||||
for (rating, count) in rows.flatten() {
|
||||
if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) {
|
||||
*slot += count as usize;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How many images carry each flag: `(picks, rejects)`.
|
||||
pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> {
|
||||
let picks: i64 = conn.query_row(
|
||||
"SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let rejects: i64 = conn.query_row(
|
||||
"SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 2",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok((picks as usize, rejects as usize))
|
||||
}
|
||||
|
||||
/// The stored integer for a flag. Matches [`crate::query::flag_code`]'s
|
||||
/// mapping — the two must agree or a filter will not find what a write stored.
|
||||
fn flag_code(f: FlagState) -> i64 {
|
||||
match f {
|
||||
FlagState::Unflagged => 0,
|
||||
FlagState::Pick => 1,
|
||||
FlagState::Reject => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn flag_from_code(v: i64) -> FlagState {
|
||||
match v {
|
||||
1 => FlagState::Pick,
|
||||
2 => FlagState::Reject,
|
||||
_ => FlagState::Unflagged,
|
||||
}
|
||||
}
|
||||
|
||||
/// A version UUID.
|
||||
///
|
||||
/// Hand-rolled rather than pulling in the `uuid` crate for one function — the
|
||||
/// same reasoning as the date maths in `library_ui`. This needs to be unique
|
||||
/// across devices, not cryptographically unguessable: it keys a merge, and an
|
||||
/// attacker who can write to the sidecar has already won.
|
||||
///
|
||||
/// Seeded from the system clock and a per-process counter, so two versions
|
||||
/// created inside the same nanosecond tick still differ.
|
||||
fn new_uuid() -> String {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// Mixed so successive ids do not share a long common prefix, which makes
|
||||
// them easier to tell apart when reading a sidecar by eye.
|
||||
let a = nanos ^ (n.wrapping_mul(0x9E37_79B9_7F4A_7C15));
|
||||
let b = nanos
|
||||
.rotate_left(32)
|
||||
.wrapping_add(n.wrapping_mul(0xBF58_476D_1CE4_E5B9));
|
||||
|
||||
format!(
|
||||
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
|
||||
(a >> 32) as u32,
|
||||
(a >> 16) as u16,
|
||||
(a & 0x0FFF) as u16,
|
||||
// Variant bits, so this is a well-formed v4-shaped UUID rather than
|
||||
// something that merely looks like one.
|
||||
((b >> 48) as u16 & 0x3FFF) | 0x8000,
|
||||
b & 0xFFFF_FFFF_FFFF,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Catalog;
|
||||
|
||||
/// A catalog holding `n` images and nothing else — the state a scan
|
||||
/// leaves behind before this module runs.
|
||||
fn with_images(n: usize) -> Catalog {
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
for i in 0..n {
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
|
||||
[format!("img{i:03}.CR3")],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
cat
|
||||
}
|
||||
|
||||
fn ids(cat: &Catalog) -> Vec<ImageId> {
|
||||
let mut stmt = cat
|
||||
.connection()
|
||||
.prepare("SELECT id FROM images ORDER BY id")
|
||||
.unwrap();
|
||||
stmt.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_scanned_image_gets_a_default_version() {
|
||||
// The gap this module exists to close: a scan inserted images and no
|
||||
// versions, so there was nowhere for a rating to go.
|
||||
let cat = with_images(5);
|
||||
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 5);
|
||||
|
||||
let n: i64 = cat
|
||||
.connection()
|
||||
.query_row(
|
||||
"SELECT count(*) FROM versions WHERE is_default = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(n, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_enter_unrated_rather_than_at_one_star() {
|
||||
// "Not yet judged" is the state a cull starts from and resumes to.
|
||||
let cat = with_images(3);
|
||||
ensure_default_versions(cat.connection()).unwrap();
|
||||
|
||||
for id in ids(&cat) {
|
||||
let j = judgement(cat.connection(), id).unwrap();
|
||||
assert_eq!(j.rating, 0);
|
||||
assert_eq!(j.flag, FlagState::Unflagged);
|
||||
assert!(!j.is_judged());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfilling_twice_creates_nothing_the_second_time() {
|
||||
// Runs on every scan, so a second pass must not double every version.
|
||||
let cat = with_images(4);
|
||||
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 4);
|
||||
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 0);
|
||||
|
||||
let n: i64 = cat
|
||||
.connection()
|
||||
.query_row("SELECT count(*) FROM versions", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 4, "one version per image, not two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_uuids_are_unique_across_a_batch() {
|
||||
// The uuid is the cross-device merge identity: two images sharing one
|
||||
// silently fuse their edits at the next sync.
|
||||
let cat = with_images(200);
|
||||
ensure_default_versions(cat.connection()).unwrap();
|
||||
|
||||
let distinct: i64 = cat
|
||||
.connection()
|
||||
.query_row("SELECT count(DISTINCT uuid) FROM versions", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(distinct, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rating_round_trips() {
|
||||
let cat = with_images(1);
|
||||
let id = ids(&cat)[0];
|
||||
set_rating(cat.connection(), id, 4).unwrap();
|
||||
assert_eq!(judgement(cat.connection(), id).unwrap().rating, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rating_an_image_with_no_version_creates_one() {
|
||||
// A library scanned by a build predating this module, or a scan that
|
||||
// died between the image insert and the version pass. The keystroke
|
||||
// must still land.
|
||||
let cat = with_images(1);
|
||||
let id = ids(&cat)[0];
|
||||
// Deliberately *not* calling ensure_default_versions first.
|
||||
set_rating(cat.connection(), id, 3).unwrap();
|
||||
assert_eq!(judgement(cat.connection(), id).unwrap().rating, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_out_of_range_rating_is_clamped_rather_than_stored() {
|
||||
// A stored 9 would sort above five stars forever and no filter would
|
||||
// reach it.
|
||||
let cat = with_images(1);
|
||||
let id = ids(&cat)[0];
|
||||
set_rating(cat.connection(), id, 99).unwrap();
|
||||
assert_eq!(judgement(cat.connection(), id).unwrap().rating, MAX_RATING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rating_back_to_zero_returns_an_image_to_unrated() {
|
||||
// Pressing 0 is how a mistake is undone, so it has to be reachable —
|
||||
// not a floor at one star.
|
||||
let cat = with_images(1);
|
||||
let id = ids(&cat)[0];
|
||||
set_rating(cat.connection(), id, 5).unwrap();
|
||||
set_rating(cat.connection(), id, 0).unwrap();
|
||||
|
||||
let j = judgement(cat.connection(), id).unwrap();
|
||||
assert_eq!(j.rating, 0);
|
||||
assert!(!j.is_judged(), "back to unjudged, so a cull re-presents it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_and_stars_are_independent_axes() {
|
||||
// Rejecting a four-star frame is a normal thing to do while culling,
|
||||
// and one axis must not clear the other.
|
||||
let cat = with_images(1);
|
||||
let id = ids(&cat)[0];
|
||||
set_rating(cat.connection(), id, 4).unwrap();
|
||||
set_flag(cat.connection(), id, FlagState::Reject).unwrap();
|
||||
|
||||
let j = judgement(cat.connection(), id).unwrap();
|
||||
assert_eq!(j.rating, 4);
|
||||
assert_eq!(j.flag, FlagState::Reject);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flag_alone_counts_as_judged() {
|
||||
// Filter-to-unjudged must not re-present a frame the user already
|
||||
// picked, merely because they did not also star it.
|
||||
let cat = with_images(1);
|
||||
let id = ids(&cat)[0];
|
||||
set_flag(cat.connection(), id, FlagState::Pick).unwrap();
|
||||
assert!(judgement(cat.connection(), id).unwrap().is_judged());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bulk_rating_applies_to_the_whole_selection() {
|
||||
// One gesture: select forty, press 3.
|
||||
let cat = with_images(10);
|
||||
let all = ids(&cat);
|
||||
let chosen = &all[2..7];
|
||||
|
||||
assert_eq!(set_rating_many(cat.connection(), chosen, 3).unwrap(), 5);
|
||||
|
||||
for id in chosen {
|
||||
assert_eq!(judgement(cat.connection(), *id).unwrap().rating, 3);
|
||||
}
|
||||
// And nothing outside the selection moved.
|
||||
assert_eq!(judgement(cat.connection(), all[0]).unwrap().rating, 0);
|
||||
assert_eq!(judgement(cat.connection(), all[9]).unwrap().rating, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bulk_write_over_an_empty_selection_is_a_no_op() {
|
||||
let cat = with_images(3);
|
||||
assert_eq!(set_rating_many(cat.connection(), &[], 5).unwrap(), 0);
|
||||
assert_eq!(
|
||||
set_flag_many(cat.connection(), &[], FlagState::Pick).unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn judgements_reads_a_whole_window_in_one_query() {
|
||||
// The grid draws a star strip per cell; one query per cell would be
|
||||
// 120 round trips on every scroll.
|
||||
let cat = with_images(6);
|
||||
let all = ids(&cat);
|
||||
set_rating(cat.connection(), all[1], 2).unwrap();
|
||||
set_flag(cat.connection(), all[3], FlagState::Pick).unwrap();
|
||||
|
||||
let map = judgements(cat.connection(), &all).unwrap();
|
||||
assert_eq!(map.get(&all[1]).unwrap().rating, 2);
|
||||
assert_eq!(map.get(&all[3]).unwrap().flag, FlagState::Pick);
|
||||
// Never rated, so it is either absent or explicitly unrated — both
|
||||
// mean the same thing to the caller.
|
||||
assert_eq!(
|
||||
map.get(&all[5]).copied().unwrap_or_default(),
|
||||
Judgement::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_histogram_sums_to_the_library_size() {
|
||||
// A histogram that disagrees with the image count is not believable,
|
||||
// and the unrated bucket is the one a fresh library lives in.
|
||||
let cat = with_images(8);
|
||||
ensure_default_versions(cat.connection()).unwrap();
|
||||
let all = ids(&cat);
|
||||
set_rating(cat.connection(), all[0], 5).unwrap();
|
||||
set_rating(cat.connection(), all[1], 5).unwrap();
|
||||
set_rating(cat.connection(), all[2], 3).unwrap();
|
||||
|
||||
let h = rating_histogram(cat.connection()).unwrap();
|
||||
assert_eq!(h[5], 2);
|
||||
assert_eq!(h[3], 1);
|
||||
assert_eq!(h[0], 5, "the rest are still unrated");
|
||||
assert_eq!(h.iter().sum::<usize>(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_histogram_counts_images_with_no_version_as_unrated() {
|
||||
// They are unrated. Dropping them would make the counts disagree with
|
||||
// the grid, which is the failure the LEFT JOIN exists to prevent.
|
||||
let cat = with_images(4);
|
||||
// No ensure_default_versions call at all.
|
||||
let h = rating_histogram(cat.connection()).unwrap();
|
||||
assert_eq!(h[0], 4);
|
||||
assert_eq!(h.iter().sum::<usize>(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_counts_separate_picks_from_rejects() {
|
||||
let cat = with_images(5);
|
||||
let all = ids(&cat);
|
||||
set_flag(cat.connection(), all[0], FlagState::Pick).unwrap();
|
||||
set_flag(cat.connection(), all[1], FlagState::Pick).unwrap();
|
||||
set_flag(cat.connection(), all[2], FlagState::Reject).unwrap();
|
||||
|
||||
assert_eq!(flag_counts(cat.connection()).unwrap(), (2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unflagging_removes_an_image_from_both_counts() {
|
||||
let cat = with_images(2);
|
||||
let all = ids(&cat);
|
||||
set_flag(cat.connection(), all[0], FlagState::Reject).unwrap();
|
||||
set_flag(cat.connection(), all[0], FlagState::Unflagged).unwrap();
|
||||
assert_eq!(flag_counts(cat.connection()).unwrap(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rating_survives_the_selector_that_queries_it() {
|
||||
// The end-to-end property: what this module writes is what
|
||||
// `dr_catalog::query` compiles `Selector::Rating` to find. These are
|
||||
// two independent pieces of SQL and they must agree on where a rating
|
||||
// lives, or rating an image would appear to do nothing.
|
||||
use crate::Query;
|
||||
use dr_types::Selector;
|
||||
|
||||
let cat = with_images(6);
|
||||
let all = ids(&cat);
|
||||
ensure_default_versions(cat.connection()).unwrap();
|
||||
set_rating(cat.connection(), all[0], 5).unwrap();
|
||||
set_rating(cat.connection(), all[1], 4).unwrap();
|
||||
set_rating(cat.connection(), all[2], 1).unwrap();
|
||||
|
||||
let q = Query {
|
||||
filter: Selector::Rating { min: 4 },
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(cat.count(&q, 0).unwrap(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flag_survives_the_selector_that_queries_it() {
|
||||
// Same contract for the other axis: `flag_code` here and in `query`
|
||||
// are separate mappings and must not drift.
|
||||
use crate::Query;
|
||||
use dr_types::Selector;
|
||||
|
||||
let cat = with_images(4);
|
||||
let all = ids(&cat);
|
||||
ensure_default_versions(cat.connection()).unwrap();
|
||||
set_flag(cat.connection(), all[0], FlagState::Pick).unwrap();
|
||||
set_flag(cat.connection(), all[1], FlagState::Reject).unwrap();
|
||||
|
||||
let picks = Query {
|
||||
filter: Selector::Flag(FlagState::Pick),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(cat.count(&picks, 0).unwrap(), 1);
|
||||
|
||||
let rejects = Query {
|
||||
filter: Selector::Flag(FlagState::Reject),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(cat.count(&rejects, 0).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unjudged_is_reachable_as_a_filter() {
|
||||
// FR-CULL-4's "filter to unjudged", which is what lets a session
|
||||
// resume where it stopped.
|
||||
use crate::Query;
|
||||
use dr_types::Selector;
|
||||
|
||||
let cat = with_images(5);
|
||||
let all = ids(&cat);
|
||||
ensure_default_versions(cat.connection()).unwrap();
|
||||
set_rating(cat.connection(), all[0], 2).unwrap();
|
||||
|
||||
let q = Query {
|
||||
filter: Selector::Rating { min: 0 },
|
||||
..Default::default()
|
||||
};
|
||||
// `min: 0` matches everything, so unjudged needs the negation.
|
||||
assert_eq!(cat.count(&q, 0).unwrap(), 5);
|
||||
|
||||
let unrated = Query {
|
||||
filter: Selector::Not(Box::new(Selector::Rating { min: 1 })),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(cat.count(&unrated, 0).unwrap(), 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! TRACES: FR-CAT-1 | FR-CAT-9 | NFR-P1
|
||||
//! Incremental scan: the local analogue of ETag pruning.
|
||||
//!
|
||||
//! Nextcloud propagates ETags up the tree, so one request proves a whole
|
||||
//! library unchanged (ARCH §8.4). A filesystem offers no such guarantee — a
|
||||
//! directory's mtime moves when its *direct* entries change and not when a
|
||||
//! grandchild does, so there is no cheap "did anything below here change"
|
||||
//! probe.
|
||||
//!
|
||||
//! Local scan therefore prunes at each level rather than at the root: one
|
||||
//! metadata probe per directory when nothing changed, instead of one per file.
|
||||
//! A 50k-image library in ~2k folders costs 2k probes, which is the difference
|
||||
//! between meeting and missing NFR-P1 on SAF.
|
||||
//!
|
||||
//! This module holds the decision logic and the deletion-sweep rules; walking
|
||||
//! an actual directory belongs to the platform layer, which supplies
|
||||
//! [`DirState`] and [`DirEntry`]. [`crate::walk`] is what puts the two
|
||||
//! together.
|
||||
|
||||
pub use dr_types::{DirEntry, DirState};
|
||||
|
||||
use dr_types::FormatFilter;
|
||||
|
||||
/// What the scanner should do with a directory, before listing it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DirAction {
|
||||
/// Contents unchanged. Skip the listing, but still recurse into known
|
||||
/// children — without upward propagation, a deep change is invisible from
|
||||
/// here.
|
||||
RecurseOnly,
|
||||
/// List and reconcile, then recurse.
|
||||
ListAndRecurse,
|
||||
}
|
||||
|
||||
/// Decide whether a directory needs listing.
|
||||
pub fn classify_dir(stored: Option<DirState>, current: DirState) -> DirAction {
|
||||
match stored {
|
||||
Some(s) if s == current => DirAction::RecurseOnly,
|
||||
_ => DirAction::ListAndRecurse,
|
||||
}
|
||||
}
|
||||
|
||||
/// What reconciling one listed entry against the catalog implies.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EntryAction {
|
||||
/// Not catalogued. Insert at `metadata_state = 1` and queue EXIF.
|
||||
Insert,
|
||||
/// Catalogued and unchanged. The common case, and it must cost nothing.
|
||||
Unchanged,
|
||||
/// Size or mtime moved: re-read metadata, rebuild the thumbnail, and drop
|
||||
/// the content hash, which is no longer valid.
|
||||
Changed,
|
||||
/// Recognised but not a format the user asked to scan for.
|
||||
Ignored,
|
||||
}
|
||||
|
||||
/// What the catalog already holds for a source.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct KnownFile {
|
||||
pub size: u64,
|
||||
pub mtime: i64,
|
||||
}
|
||||
|
||||
/// Classify one listed file.
|
||||
pub fn classify_entry(
|
||||
entry: &DirEntry,
|
||||
known: Option<KnownFile>,
|
||||
formats: &FormatFilter,
|
||||
) -> EntryAction {
|
||||
if !formats.allows_name(&entry.name) {
|
||||
return EntryAction::Ignored;
|
||||
}
|
||||
match known {
|
||||
None => EntryAction::Insert,
|
||||
Some(k) if k.size == entry.size && k.mtime == entry.mtime => EntryAction::Unchanged,
|
||||
Some(_) => EntryAction::Changed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a scan, which decides whether pruning may run.
|
||||
///
|
||||
/// `Cancelled` is the default because a scan that has not run has proven
|
||||
/// nothing absent, and every default in this area must fail towards keeping
|
||||
/// photographs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ScanOutcome {
|
||||
/// Every reachable folder was visited.
|
||||
Complete,
|
||||
/// The user cancelled. Partial state is valid — jobs are resumable — but
|
||||
/// unvisited folders must not be read as deleted.
|
||||
#[default]
|
||||
Cancelled,
|
||||
/// The root itself could not be opened: drive unplugged, SAF grant
|
||||
/// revoked, share unmounted.
|
||||
RootUnreachable,
|
||||
/// Some subtree failed while the root was fine.
|
||||
PartialFailure,
|
||||
}
|
||||
|
||||
impl ScanOutcome {
|
||||
/// Whether the deletion sweep may run.
|
||||
///
|
||||
/// **The most dangerous decision in the catalog.** The sweep deletes every
|
||||
/// folder not reached by this scan's generation. After an incomplete scan
|
||||
/// that is most of the library, so it runs only on `Complete`.
|
||||
///
|
||||
/// FR-CAT-9 draws exactly this line: a source *proven absent* may leave
|
||||
/// the catalog; a source merely *unreachable* is marked offline and kept,
|
||||
/// with its ratings and edits intact.
|
||||
pub fn may_prune(self) -> bool {
|
||||
matches!(self, ScanOutcome::Complete)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_types::Format;
|
||||
|
||||
const A: DirState = DirState {
|
||||
mtime: 100,
|
||||
entry_count: 5,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn unchanged_directory_is_not_listed() {
|
||||
assert_eq!(classify_dir(Some(A), A), DirAction::RecurseOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_never_seen_directory_is_listed() {
|
||||
assert_eq!(classify_dir(None, A), DirAction::ListAndRecurse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_mtime_forces_a_listing() {
|
||||
let now = DirState { mtime: 101, ..A };
|
||||
assert_eq!(classify_dir(Some(A), now), DirAction::ListAndRecurse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_count_catches_what_mtime_misses() {
|
||||
// A file added within the same timestamp tick: mtime is unchanged, so
|
||||
// mtime alone would skip this directory and lose the new image.
|
||||
let now = DirState {
|
||||
mtime: 100,
|
||||
entry_count: 6,
|
||||
};
|
||||
assert_eq!(classify_dir(Some(A), now), DirAction::ListAndRecurse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_file_costs_nothing() {
|
||||
let e = DirEntry {
|
||||
name: "IMG_0001.CR3".into(),
|
||||
is_dir: false,
|
||||
size: 30_000_000,
|
||||
mtime: 500,
|
||||
};
|
||||
let known = KnownFile {
|
||||
size: 30_000_000,
|
||||
mtime: 500,
|
||||
};
|
||||
assert_eq!(
|
||||
classify_entry(&e, Some(known), &FormatFilter::all()),
|
||||
EntryAction::Unchanged
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resaved_file_is_reprocessed() {
|
||||
let e = DirEntry {
|
||||
name: "IMG_0001.CR3".into(),
|
||||
is_dir: false,
|
||||
size: 30_000_001,
|
||||
mtime: 900,
|
||||
};
|
||||
let known = KnownFile {
|
||||
size: 30_000_000,
|
||||
mtime: 500,
|
||||
};
|
||||
assert_eq!(
|
||||
classify_entry(&e, Some(known), &FormatFilter::all()),
|
||||
EntryAction::Changed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_filter_excludes_unwanted_types() {
|
||||
let jpeg = DirEntry {
|
||||
name: "IMG_0001.JPG".into(),
|
||||
is_dir: false,
|
||||
size: 1,
|
||||
mtime: 1,
|
||||
};
|
||||
assert_eq!(
|
||||
classify_entry(&jpeg, None, &FormatFilter::raw_only()),
|
||||
EntryAction::Ignored
|
||||
);
|
||||
assert_eq!(
|
||||
classify_entry(&jpeg, None, &FormatFilter::all()),
|
||||
EntryAction::Insert
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placeholder_is_catalogued_as_the_image_it_stands_for() {
|
||||
// 121,785 of these in a real synced folder (ARCH §9.0). Each must
|
||||
// enter the catalog as a CR2 marked offline, not be skipped as an
|
||||
// unknown ".nextcloud" type.
|
||||
let stub = DirEntry {
|
||||
name: "_MG_4130.CR2.nextcloud".into(),
|
||||
is_dir: false,
|
||||
size: 1,
|
||||
mtime: 1,
|
||||
};
|
||||
assert_eq!(
|
||||
classify_entry(&stub, None, &FormatFilter::from_formats([Format::Cr2])),
|
||||
EntryAction::Insert
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pruning_requires_a_complete_scan() {
|
||||
assert!(ScanOutcome::Complete.may_prune());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreachable_root_never_prunes() {
|
||||
// The guard that stops an unplugged drive from deleting the library:
|
||||
// every folder would look unreached, so the sweep would take all of
|
||||
// them (FR-CAT-9).
|
||||
assert!(!ScanOutcome::RootUnreachable.may_prune());
|
||||
assert!(!ScanOutcome::Cancelled.may_prune());
|
||||
assert!(!ScanOutcome::PartialFailure.may_prune());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,934 @@
|
||||
//! TRACES: FR-CAT-2 | NFR-R5
|
||||
//! Schema definition and forward-only migrations.
|
||||
//!
|
||||
//! The catalog is an *index*, not a source of truth (ARCH §6.12) — it is
|
||||
//! deletable and rebuildable from sources plus sidecars. That is what makes
|
||||
//! migration failure survivable, and why the recovery path is the normal
|
||||
//! mechanism rather than a last resort.
|
||||
//!
|
||||
//! Migrations are forward-only, transactional, and idempotent on retry
|
||||
//! (NFR-R5). The app refuses to open a catalog newer than it understands
|
||||
//! rather than corrupting it.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// Schema version this build writes and understands.
|
||||
pub const SCHEMA_VERSION: i64 = 6;
|
||||
|
||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
/// Returns the version migrated from, so callers can log or back up before a
|
||||
/// real migration (NFR-R2 requires a backup before schema change).
|
||||
pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
||||
let from: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
|
||||
if from > SCHEMA_VERSION {
|
||||
return Err(CatalogError::SchemaTooNew {
|
||||
found: from,
|
||||
supported: SCHEMA_VERSION,
|
||||
});
|
||||
}
|
||||
if from == SCHEMA_VERSION {
|
||||
return Ok(from);
|
||||
}
|
||||
|
||||
// Each step runs in its own transaction so a failure leaves the catalog
|
||||
// at a coherent version rather than half-migrated.
|
||||
if from < 1 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V1)?;
|
||||
tx.pragma_update(None, "user_version", 1)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if from < 2 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V2)?;
|
||||
tx.pragma_update(None, "user_version", 2)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if from < 3 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V3)?;
|
||||
tx.pragma_update(None, "user_version", 3)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if from < 4 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V4)?;
|
||||
tx.pragma_update(None, "user_version", 4)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if from < 5 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V5)?;
|
||||
tx.pragma_update(None, "user_version", 5)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if from < 6 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V6)?;
|
||||
tx.pragma_update(None, "user_version", 6)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(from)
|
||||
}
|
||||
|
||||
/// Recompute columns a migration added, for rows that predate it.
|
||||
///
|
||||
/// A migration adds a column with a default; it cannot know what the value
|
||||
/// *should* be for the rows already present. Without a backfill those rows are
|
||||
/// silently partial — present, queryable, and wrong — which is worse than
|
||||
/// missing, because nothing signals that they need attention.
|
||||
///
|
||||
/// Cheap enough to run on every open: each pass is one indexed UPDATE, and
|
||||
/// re-running it is a no-op once the values are already right.
|
||||
///
|
||||
/// Returns how many rows each backfill touched, for logging.
|
||||
pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, CatalogError> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
// v2: `shadowed_by`. A JPEG sitting beside a RAW of the same name is the
|
||||
// camera's own rendering of that frame, not a second photograph, so it is
|
||||
// hidden from the grid, the timeline and the sweep.
|
||||
let n = pair_raw_and_jpeg(conn)?;
|
||||
if n > 0 {
|
||||
out.push(("shadowed_by", n));
|
||||
}
|
||||
|
||||
// v3: every image needs a default version to carry its rating and flag.
|
||||
// Libraries scanned before ratings existed have images and no versions at
|
||||
// all, so there was nowhere for a judgement to go — see
|
||||
// [`crate::rating`]. Backfilled rather than migrated in SQL because the
|
||||
// UUID per row is the cross-device merge identity and must be generated,
|
||||
// not derived.
|
||||
let n = crate::rating::ensure_default_versions(conn)?;
|
||||
if n > 0 {
|
||||
out.push(("default_versions", n));
|
||||
}
|
||||
|
||||
// v6: a vocabulary row for every word some image already carries.
|
||||
//
|
||||
// Three ways a catalog arrives holding assignments with no term behind
|
||||
// them, and all three are normal rather than exceptional: a library
|
||||
// keyworded by a build that predates this table, a catalog rebuilt from
|
||||
// sidecars (which carry the word and not the identity), and an import from
|
||||
// Lightroom or darktable (FR-CAT-14). Without this the words are
|
||||
// searchable but absent from the vocabulary list, which reads as the
|
||||
// keywords having been lost.
|
||||
let n = crate::keywords::adopt_orphan_terms(conn)?;
|
||||
if n > 0 {
|
||||
out.push(("keyword_terms", n));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Connection setup applied on every open, migration or not.
|
||||
///
|
||||
/// WAL is required by NFR-R1: it survives power loss without corruption, and
|
||||
/// it lets a background job write while the grid reads.
|
||||
pub fn configure(conn: &Connection) -> Result<(), CatalogError> {
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
// NORMAL rather than FULL: with WAL this is durable across process death
|
||||
// (which is what FR-PLAT-AND-3 cares about) and only risks the last
|
||||
// transaction on power loss. The catalog is rebuildable; the sidecars are
|
||||
// not, and they are written separately with their own fsync discipline.
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", true)?;
|
||||
// A scan touching thousands of rows is transient; let SQLite spill to
|
||||
// memory rather than materialising temp b-trees on disk.
|
||||
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The v1 schema rewritten to target an attached database.
|
||||
///
|
||||
/// Needed because a downloaded remote catalog is `ATTACH`ed under its own
|
||||
/// schema name before merging, and tests build one from scratch. SQLite has no
|
||||
/// "create these tables over there" form, so the names are rewritten.
|
||||
///
|
||||
/// The rewrite is textual and therefore only as good as the naming discipline
|
||||
/// in [`V1`]: every `CREATE TABLE`/`CREATE INDEX` must name its object
|
||||
/// unqualified, which they do.
|
||||
pub fn v1_for_attached(schema_name: &str) -> String {
|
||||
rewrite_for_attached(V1, schema_name)
|
||||
// REFERENCES within an attached schema resolve to that schema already,
|
||||
// so foreign keys need no rewriting — but the ON clause of an index
|
||||
// does, and `CREATE INDEX x.name ON table` is the correct form.
|
||||
}
|
||||
|
||||
/// Every table this build knows about, rewritten to target an attached
|
||||
/// database.
|
||||
///
|
||||
/// [`v1_for_attached`] is kept alongside this rather than replaced by it: a
|
||||
/// remote catalog written by an older build genuinely has only the v1 tables,
|
||||
/// and the merge has to keep working against one (see
|
||||
/// [`crate::merge::merge_keywords`]). Building that case in a test needs a way
|
||||
/// to say "v1 and no more".
|
||||
///
|
||||
/// Only the migrations that *create* objects appear here. V2 through V5 are
|
||||
/// `ALTER TABLE ... ADD COLUMN`, and the columns they add are local index
|
||||
/// state — shadowing, trashing, cache pinning — that a merge never reads
|
||||
/// across the attachment.
|
||||
pub fn for_attached(schema_name: &str) -> String {
|
||||
format!(
|
||||
"{}\n{}",
|
||||
rewrite_for_attached(V1, schema_name),
|
||||
rewrite_for_attached(V6, schema_name)
|
||||
)
|
||||
}
|
||||
|
||||
/// Qualify every object a `CREATE` statement names with `schema_name`.
|
||||
///
|
||||
/// The rewrite is textual and therefore only as good as the naming discipline
|
||||
/// in the batches it is given: every `CREATE TABLE`/`CREATE INDEX` must name
|
||||
/// its object unqualified, which they do.
|
||||
fn rewrite_for_attached(sql: &str, schema_name: &str) -> String {
|
||||
sql.replace("CREATE TABLE ", &format!("CREATE TABLE {schema_name}."))
|
||||
.replace("CREATE INDEX ", &format!("CREATE INDEX {schema_name}."))
|
||||
.replace(
|
||||
"CREATE UNIQUE INDEX ",
|
||||
&format!("CREATE UNIQUE INDEX {schema_name}."),
|
||||
)
|
||||
}
|
||||
|
||||
/// Mark each JPEG that sits beside a RAW of the same name.
|
||||
///
|
||||
/// Matched on folder plus stem, case-insensitively. Same-folder is what makes
|
||||
/// this safe: cameras write the pair side by side, and matching across folders
|
||||
/// would risk pairing unrelated frames, since camera filenames wrap at
|
||||
/// IMG_9999 (FR-CAT-11).
|
||||
///
|
||||
/// Done in Rust rather than SQL because the comparison needs a filename stem,
|
||||
/// and SQLite has no such function without enabling `rusqlite/functions` —
|
||||
/// a dependency feature for one string operation, whose SQL spelling would be
|
||||
/// unreadable and would mishandle names with no extension.
|
||||
fn pair_raw_and_jpeg(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// (folder, lowercase stem) -> RAW id, built in one pass over the RAWs.
|
||||
let mut raws: HashMap<(Option<i64>, String), i64> = HashMap::new();
|
||||
{
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, folder_id, source_ref FROM images
|
||||
WHERE lower(format) IN
|
||||
('cr2','cr3','nef','arw','raf','rw2','orf','dng')",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, Option<i64>>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (id, folder, path) = row?;
|
||||
raws.insert((folder, stem_of(&path).to_ascii_lowercase()), id);
|
||||
}
|
||||
}
|
||||
if raws.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let pairs: Vec<(i64, i64)> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, folder_id, source_ref FROM images
|
||||
WHERE lower(format) IN ('jpg','jpeg') AND shadowed_by IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, Option<i64>>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
))
|
||||
})?;
|
||||
rows.filter_map(|row| {
|
||||
let (id, folder, path) = row.ok()?;
|
||||
let raw = raws.get(&(folder, stem_of(&path).to_ascii_lowercase()))?;
|
||||
Some((id, *raw))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for (jpeg, raw) in &pairs {
|
||||
tx.execute(
|
||||
"UPDATE images SET shadowed_by = ?2 WHERE id = ?1",
|
||||
[jpeg, raw],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(pairs.len())
|
||||
}
|
||||
|
||||
/// A filename without its extension.
|
||||
///
|
||||
/// Only the final path component, and only its last dot — a directory
|
||||
/// containing a dot must not truncate the name.
|
||||
fn stem_of(path: &str) -> &str {
|
||||
let name = path.rsplit(['/', ':']).next().unwrap_or(path);
|
||||
match name.rsplit_once('.') {
|
||||
Some((stem, _)) if !stem.is_empty() => stem,
|
||||
_ => name,
|
||||
}
|
||||
}
|
||||
|
||||
const V6: &str = r#"
|
||||
-- TRACES: FR-CAT-5 | FR-CAT-6 | FR-NC-9
|
||||
-- Keywords gain an identity, so that renaming and deleting one can cross
|
||||
-- between devices.
|
||||
--
|
||||
-- The v1 `keywords` table is the *assignment*: one row per (version, word),
|
||||
-- and the word is stored as text. That stays exactly as it is, and this
|
||||
-- migration adds nothing to it, for a reason that is easy to get backwards.
|
||||
--
|
||||
-- # Why assignments keep the text rather than pointing at a row here
|
||||
--
|
||||
-- The catalog is a rebuildable index (ARCH §6.12). What an image is keyworded
|
||||
-- with is authoritative in the sidecar and in XMP `dc:subject` (FR-CAT-13),
|
||||
-- and both of those carry a *string*. Rewriting the join to reference
|
||||
-- `keyword_terms(id)` would mean a catalog rebuilt from sidecars had to invent
|
||||
-- term rows before it could record a single assignment, and an integer that
|
||||
-- means nothing on the other device would sit where the durable fact belongs.
|
||||
-- It would also break `crate::query`, which matches `kw.keyword` directly and
|
||||
-- must keep hitting `keywords_term` on a 50k library (FR-CAT-6).
|
||||
--
|
||||
-- So the text is the fact and this table is the *identity*: it exists to give
|
||||
-- a rename and a deletion something a merge can key on, and to let a keyword
|
||||
-- exist in the vocabulary before any photograph carries it.
|
||||
CREATE TABLE keyword_terms (
|
||||
id INTEGER PRIMARY KEY,
|
||||
-- Device-independent identity, as `collections.uuid` is. The integer id is
|
||||
-- local and collides across devices.
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
-- The word itself, and the value written into every assignment row.
|
||||
name TEXT NOT NULL,
|
||||
created INTEGER NOT NULL,
|
||||
-- Monotonic, bumped on every local edit. `crate::merge` compares these
|
||||
-- rather than timestamps, so a clock-skewed device cannot silently win.
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
modified INTEGER NOT NULL,
|
||||
-- Tombstone, so a merge against a device that still holds the keyword does
|
||||
-- not resurrect it.
|
||||
deleted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Deliberately **not** UNIQUE.
|
||||
--
|
||||
-- Two devices that each type "Iceland" create two rows with two uuids, and
|
||||
-- both are correct until they meet. A unique constraint would abort the merge
|
||||
-- transaction at exactly that moment — the ordinary case, not a corner one.
|
||||
-- Uniqueness is instead reached by convergence: `crate::keywords::create`
|
||||
-- resolves an existing name locally, and `crate::keywords::fuse_duplicates`
|
||||
-- collapses a cross-device pair onto the lexicographically smaller uuid, which
|
||||
-- both devices compute identically without talking to each other.
|
||||
--
|
||||
-- Partial on `deleted = 0` because every lookup here is a live one: the
|
||||
-- vocabulary list, the resolve-by-name in `create`, and the fuse pass all
|
||||
-- exclude tombstones, and including them would grow the index with every
|
||||
-- keyword the library has ever had rather than with the ones it has.
|
||||
CREATE INDEX keyword_terms_name ON keyword_terms(name) WHERE deleted = 0;
|
||||
"#;
|
||||
|
||||
const V5: &str = r#"
|
||||
-- TRACES: FR-NC-6a | FR-CAT-9 | NFR-RES-4
|
||||
-- Offline availability: what is kept, why it is kept, and where it lives.
|
||||
--
|
||||
-- `pinned` separates a promise from a convenience, and the distinction has to
|
||||
-- be a *column* rather than something inferred from `pinned_by_rule`. A pin is
|
||||
-- the user saying "this collection comes with me"; a passively cached original
|
||||
-- is the app noticing they opened something. Only the second is evictable, so
|
||||
-- the eviction query has to be able to ask the question directly — and it has
|
||||
-- to keep answering correctly for an image whose pinning rule was since
|
||||
-- deleted, which `pinned_by_rule` alone cannot do because it is
|
||||
-- ON DELETE SET NULL.
|
||||
ALTER TABLE image_cache ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Where the cached original actually is, relative to the cache directory.
|
||||
-- Relative rather than absolute: the library moves between machines and
|
||||
-- between an app sandbox and a user directory, and an absolute path baked in
|
||||
-- at download time would break on every one of those.
|
||||
ALTER TABLE image_cache ADD COLUMN path TEXT;
|
||||
|
||||
-- Eviction reads exactly this: unpinned rows, oldest use first. Partial on
|
||||
-- `pinned = 0` because pinned rows are never candidates and including them
|
||||
-- would make the index proportional to the whole library rather than to the
|
||||
-- passive cache.
|
||||
CREATE INDEX image_cache_evictable ON image_cache(last_used)
|
||||
WHERE pinned = 0;
|
||||
"#;
|
||||
|
||||
const V4: &str = r#"
|
||||
-- TRACES: FR-CAT-15
|
||||
-- Soft delete. A trashed image is a real file that has been *moved* to a trash
|
||||
-- folder under the library root, not a row hidden by a flag: the catalog is a
|
||||
-- rebuildable index (ARCH §6.12), so a flag alone would evaporate the moment
|
||||
-- the catalog was deleted and every trashed photograph would return.
|
||||
--
|
||||
-- `source_ref` follows the file to its new path, because that is where the bytes
|
||||
-- now are and every fetch resolves through it. `trashed_from` remembers where it
|
||||
-- came from, which is the only way a restore can put it back — the trash is flat
|
||||
-- and the original folder structure is not recoverable from the trashed path.
|
||||
ALTER TABLE images ADD COLUMN trashed_at INTEGER;
|
||||
ALTER TABLE images ADD COLUMN trashed_from TEXT;
|
||||
|
||||
-- Partial: almost no rows are trashed, and the grid's "not trashed" predicate is
|
||||
-- answered by the absence of an entry rather than by scanning every image.
|
||||
CREATE INDEX images_trashed ON images(trashed_at) WHERE trashed_at IS NOT NULL;
|
||||
"#;
|
||||
|
||||
const V3: &str = r#"
|
||||
-- Ratings and flags are read per grid window and counted for the filter bar's
|
||||
-- histogram, both of which key on the *default* version. Without this the
|
||||
-- histogram is a full scan of `versions` on every judgement.
|
||||
--
|
||||
-- Partial on `is_default`: a virtual copy's rating is real but is never what
|
||||
-- these two queries ask for, and excluding them keeps the index roughly one
|
||||
-- entry per image rather than one per version.
|
||||
CREATE INDEX versions_judgement ON versions(image_id, rating, flag)
|
||||
WHERE is_default = 1;
|
||||
"#;
|
||||
|
||||
const V2: &str = r#"
|
||||
-- A JPEG the camera wrote alongside a RAW of the same name is that RAW's own
|
||||
-- rendering, not a second photograph. Recording *which* RAW shadows it, rather
|
||||
-- than a bare flag, keeps the relationship usable: the JPEG is a ready-made
|
||||
-- preview for its RAW, and the pairing can be undone without a rescan.
|
||||
ALTER TABLE images ADD COLUMN shadowed_by INTEGER REFERENCES images(id) ON DELETE SET NULL;
|
||||
CREATE INDEX images_shadowed ON images(shadowed_by) WHERE shadowed_by IS NOT NULL;
|
||||
"#;
|
||||
|
||||
const V1: &str = r#"
|
||||
-- Roots -------------------------------------------------------------------
|
||||
CREATE TABLE roots (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL, -- 'local' | 'saf' | 'remote'
|
||||
grant_blob BLOB, -- SAF persisted permission; NULL on Linux
|
||||
label TEXT NOT NULL,
|
||||
last_seen INTEGER,
|
||||
-- Bumped once per completed scan. Folders record the generation they were
|
||||
-- reached in; anything older was not reached and no longer exists.
|
||||
scan_generation INTEGER NOT NULL DEFAULT 0,
|
||||
-- One row per granted location. Without this, a rescan inserts a second
|
||||
-- root for the same folder and the library silently fragments across
|
||||
-- them — images split between roots, and pruning compares against the
|
||||
-- wrong generation.
|
||||
UNIQUE(kind, label)
|
||||
);
|
||||
|
||||
-- Folders: the unit of change detection, local and remote alike -----------
|
||||
CREATE TABLE folders (
|
||||
id INTEGER PRIMARY KEY,
|
||||
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
|
||||
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
-- Remote: the propagating ETag that makes a no-op sync one request.
|
||||
etag TEXT,
|
||||
-- Local: directory mtime plus direct-entry count. mtime alone misses a
|
||||
-- paired create+delete inside one timestamp tick; the count narrows that.
|
||||
mtime INTEGER,
|
||||
entry_count INTEGER,
|
||||
scanned_generation INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(root_id, path)
|
||||
);
|
||||
CREATE INDEX folders_parent ON folders(parent_id);
|
||||
|
||||
-- Images ------------------------------------------------------------------
|
||||
CREATE TABLE images (
|
||||
id INTEGER PRIMARY KEY,
|
||||
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
|
||||
folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
||||
source_ref TEXT NOT NULL,
|
||||
-- Expensive: requires reading the whole file. Computed only when
|
||||
-- something needs it (import dedup, reconnect-by-hash), never in a scan.
|
||||
content_hash TEXT,
|
||||
format TEXT,
|
||||
w INTEGER,
|
||||
h INTEGER,
|
||||
-- UTC seconds. NULL until EXIF is read, or if the file carries none.
|
||||
captured_at INTEGER,
|
||||
-- Minutes east of UTC. A photograph's timestamp is local to where it was
|
||||
-- taken; storing UTC alone makes a Tokyo shoot span two days in Paris.
|
||||
captured_offset INTEGER,
|
||||
camera TEXT,
|
||||
lens TEXT,
|
||||
iso INTEGER,
|
||||
aperture REAL,
|
||||
shutter REAL,
|
||||
availability INTEGER NOT NULL DEFAULT 0,
|
||||
file_size INTEGER,
|
||||
file_mtime INTEGER,
|
||||
-- 0 = nothing, 1 = stat-only, 2 = full EXIF. The grid is usable at 1.
|
||||
metadata_state INTEGER NOT NULL DEFAULT 0,
|
||||
sidecar_mtime INTEGER,
|
||||
added_at INTEGER NOT NULL,
|
||||
UNIQUE(root_id, source_ref)
|
||||
);
|
||||
CREATE INDEX images_captured ON images(captured_at);
|
||||
CREATE INDEX images_folder ON images(folder_id);
|
||||
-- Partial: content_hash is NULL for most rows most of the time, and the
|
||||
-- non-NULL subset is exactly what reconnect and dedup query.
|
||||
CREATE INDEX images_hash ON images(content_hash) WHERE content_hash IS NOT NULL;
|
||||
|
||||
-- Versions ----------------------------------------------------------------
|
||||
CREATE TABLE versions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
graph_hash TEXT,
|
||||
rating INTEGER NOT NULL DEFAULT 0,
|
||||
label INTEGER,
|
||||
flag INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX versions_image ON versions(image_id);
|
||||
|
||||
CREATE TABLE keywords (
|
||||
version_id INTEGER NOT NULL REFERENCES versions(id) ON DELETE CASCADE,
|
||||
keyword TEXT NOT NULL,
|
||||
PRIMARY KEY(version_id, keyword)
|
||||
);
|
||||
CREATE INDEX keywords_term ON keywords(keyword);
|
||||
|
||||
-- Remote mapping ----------------------------------------------------------
|
||||
CREATE TABLE remote (
|
||||
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
|
||||
-- oc:fileid — stable across server-side rename and move, so a move is not
|
||||
-- a re-download of 80 MB.
|
||||
file_id INTEGER NOT NULL,
|
||||
etag TEXT,
|
||||
sync_state INTEGER NOT NULL DEFAULT 0,
|
||||
remote_path TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX remote_file ON remote(file_id);
|
||||
|
||||
-- Collections -------------------------------------------------------------
|
||||
CREATE TABLE collections (
|
||||
id INTEGER PRIMARY KEY,
|
||||
-- Device-independent identity. The integer id is local and collides
|
||||
-- across devices; the UUID is what a cross-device merge keys on.
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES collections(id) ON DELETE CASCADE,
|
||||
kind INTEGER NOT NULL, -- 0 = manual, 1 = smart
|
||||
selector_json TEXT, -- smart only
|
||||
created INTEGER NOT NULL,
|
||||
-- Monotonic per collection, bumped on every local edit. Merge compares
|
||||
-- these rather than file mtimes, so a clock-skewed device cannot silently
|
||||
-- win.
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
modified INTEGER NOT NULL,
|
||||
-- Tombstone. A deleted collection must outlive its deletion, or a merge
|
||||
-- with a device that still has it would resurrect it.
|
||||
deleted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE collection_members (
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
position INTEGER, -- manual ordering; NULL = by capture time
|
||||
added INTEGER NOT NULL,
|
||||
PRIMARY KEY(collection_id, image_id)
|
||||
);
|
||||
CREATE INDEX members_image ON collection_members(image_id);
|
||||
|
||||
-- Cache -------------------------------------------------------------------
|
||||
CREATE TABLE cache (
|
||||
id INTEGER PRIMARY KEY,
|
||||
version_id INTEGER REFERENCES versions(id) ON DELETE CASCADE,
|
||||
image_id INTEGER REFERENCES images(id) ON DELETE CASCADE,
|
||||
kind INTEGER NOT NULL, -- thumbnail | proxy | original
|
||||
resolution INTEGER,
|
||||
graph_hash TEXT,
|
||||
path TEXT NOT NULL,
|
||||
bytes INTEGER NOT NULL,
|
||||
last_used INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX cache_lru ON cache(last_used);
|
||||
|
||||
CREATE TABLE cache_rules (
|
||||
id INTEGER PRIMARY KEY,
|
||||
selector_json TEXT NOT NULL,
|
||||
tier INTEGER NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE image_cache (
|
||||
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
|
||||
tier_actual INTEGER NOT NULL DEFAULT 0,
|
||||
-- Materialised rather than recomputed, so the grid can draw availability
|
||||
-- badges without evaluating every rule for every visible cell.
|
||||
tier_desired INTEGER NOT NULL DEFAULT 0,
|
||||
bytes INTEGER NOT NULL DEFAULT 0,
|
||||
last_used INTEGER,
|
||||
pinned_by_rule INTEGER REFERENCES cache_rules(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Jobs --------------------------------------------------------------------
|
||||
CREATE TABLE jobs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind INTEGER NOT NULL,
|
||||
subject_id INTEGER,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
state INTEGER NOT NULL DEFAULT 0, -- 0=pending 1=running 2=failed
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
not_before INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT,
|
||||
last_error TEXT,
|
||||
-- Coalescing. Enqueueing the same work twice updates one row rather than
|
||||
-- queueing it twice, which is what makes "enqueue on any change" safe to
|
||||
-- call liberally.
|
||||
UNIQUE(kind, subject_id)
|
||||
);
|
||||
CREATE INDEX jobs_ready ON jobs(state, priority DESC, not_before);
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mem() -> Connection {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
configure(&c).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
/// How many rows a named backfill touched, ignoring the others.
|
||||
///
|
||||
/// Asserting on the whole vector would couple every test to which other
|
||||
/// backfills happen to exist.
|
||||
fn backfilled(c: &Connection, what: &str) -> usize {
|
||||
backfill(c)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|(name, _)| *name == what)
|
||||
.map(|(_, n)| n)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Insert an image and return its id.
|
||||
fn image(c: &Connection, folder: Option<i64>, name: &str, format: &str) -> i64 {
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, folder_id, source_ref, format, added_at)
|
||||
VALUES (1, ?1, ?2, ?3, 0)",
|
||||
rusqlite::params![folder, name, format],
|
||||
)
|
||||
.unwrap();
|
||||
c.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn with_root() -> Connection {
|
||||
let c = mem();
|
||||
migrate(&c).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO folders(id, root_id, path) VALUES (1, 1, 'a'), (2, 1, 'b')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_beside_its_raw_is_shadowed() {
|
||||
// The camera's own rendering of a frame, not a second photograph.
|
||||
let c = with_root();
|
||||
let raw = image(&c, Some(1), "a/IMG_1234.CR2", "cr2");
|
||||
let jpeg = image(&c, Some(1), "a/IMG_1234.JPG", "jpg");
|
||||
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 1);
|
||||
let got: Option<i64> = c
|
||||
.query_row(
|
||||
"SELECT shadowed_by FROM images WHERE id = ?1",
|
||||
[jpeg],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got, Some(raw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_case_does_not_matter() {
|
||||
let c = with_root();
|
||||
image(&c, Some(1), "a/IMG_1.cr2", "cr2");
|
||||
image(&c, Some(1), "a/img_1.JPG", "jpg");
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_standalone_jpeg_is_untouched() {
|
||||
// Scanned film has no RAW sibling and must stay visible — 2,656 of
|
||||
// them in the reference library.
|
||||
let c = with_root();
|
||||
image(&c, Some(1), "a/SCAN_0001.jpg", "jpg");
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_in_a_different_folder_is_not_shadowed() {
|
||||
// Camera filenames wrap at IMG_9999, so the same stem recurs across
|
||||
// shoots (FR-CAT-11). Only a same-folder pair is safe to collapse.
|
||||
let c = with_root();
|
||||
image(&c, Some(1), "a/IMG_1234.CR2", "cr2");
|
||||
image(&c, Some(2), "b/IMG_1234.JPG", "jpg");
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_is_never_shadowed_by_a_jpeg() {
|
||||
// The relationship is one-way: the RAW is the photograph.
|
||||
let c = with_root();
|
||||
let raw = image(&c, Some(1), "a/IMG_1.CR2", "cr2");
|
||||
image(&c, Some(1), "a/IMG_1.JPG", "jpg");
|
||||
backfill(&c).unwrap();
|
||||
|
||||
let got: Option<i64> = c
|
||||
.query_row("SELECT shadowed_by FROM images WHERE id = ?1", [raw], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_is_idempotent() {
|
||||
// It runs on every open, so a second pass must find nothing to do.
|
||||
let c = with_root();
|
||||
image(&c, Some(1), "a/IMG_1.CR2", "cr2");
|
||||
image(&c, Some(1), "a/IMG_1.JPG", "jpg");
|
||||
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 1);
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 0, "second pass is a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v1_catalog_gains_the_column_and_is_backfilled() {
|
||||
// The migration case that motivated this: rows already present when a
|
||||
// column is added are silently partial until something backfills them.
|
||||
let c = mem();
|
||||
c.execute_batch(V1).unwrap();
|
||||
c.pragma_update(None, "user_version", 1).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, source_ref, format, added_at)
|
||||
VALUES (1, 'IMG_9.CR2', 'cr2', 0), (1, 'IMG_9.JPG', 'jpg', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(migrate(&c).unwrap(), 1, "migrated from v1");
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v4_catalog_gains_the_pinning_columns() {
|
||||
// TRACES: FR-NC-6a
|
||||
// An existing library must not have to be rescanned to gain offline
|
||||
// pinning. The rows are already there; only the columns are new.
|
||||
let c = mem();
|
||||
c.execute_batch(V1).unwrap();
|
||||
c.execute_batch(V2).unwrap();
|
||||
c.execute_batch(V3).unwrap();
|
||||
c.execute_batch(V4).unwrap();
|
||||
c.pragma_update(None, "user_version", 4).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (7, 1, 'IMG_7.CR2', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// A cache row written before pinning existed.
|
||||
c.execute(
|
||||
"INSERT INTO image_cache(image_id, tier_actual, bytes) VALUES (7, 2, 100)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(migrate(&c).unwrap(), 4, "migrated from v4");
|
||||
|
||||
// The pre-existing row survives, and defaults to unpinned — the safe
|
||||
// direction, since claiming a pin nobody made would exempt it from
|
||||
// eviction for ever.
|
||||
let (pinned, bytes): (i64, i64) = c
|
||||
.query_row(
|
||||
"SELECT pinned, bytes FROM image_cache WHERE image_id = 7",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pinned, 0);
|
||||
assert_eq!(bytes, 100, "the existing row is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v5_catalog_keeps_its_keywords_and_gains_their_identities() {
|
||||
// TRACES: FR-CAT-5
|
||||
// The migration case that matters here: a library keyworded by an
|
||||
// import or an older build already has assignment rows, and they must
|
||||
// survive into the vocabulary rather than being left searchable but
|
||||
// invisible.
|
||||
let c = mem();
|
||||
for step in [V1, V2, V3, V4, V5] {
|
||||
c.execute_batch(step).unwrap();
|
||||
}
|
||||
c.pragma_update(None, "user_version", 5).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (7, 1, 'IMG_7.CR3', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO versions(id, image_id, uuid, name, is_default)
|
||||
VALUES (1, 7, 'v-7', 'Default', 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO keywords(version_id, keyword) VALUES (1, 'puffin')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(migrate(&c).unwrap(), 5, "migrated from v5");
|
||||
assert_eq!(backfilled(&c, "keyword_terms"), 1);
|
||||
|
||||
let name: String = c
|
||||
.query_row("SELECT name FROM keyword_terms", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(name, "puffin");
|
||||
// The assignment is untouched — it is the durable fact, and the term
|
||||
// row is only its identity.
|
||||
let n: i64 = c
|
||||
.query_row("SELECT count(*) FROM keywords", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
|
||||
// It runs on every open, so a second pass must find nothing to do.
|
||||
assert_eq!(backfilled(&c, "keyword_terms"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_devices_may_both_hold_a_term_of_the_same_name() {
|
||||
// Deliberately not a unique index. Two devices each typing "Iceland"
|
||||
// is the ordinary case, and a constraint would abort the merge
|
||||
// transaction at exactly the moment they first sync.
|
||||
let c = mem();
|
||||
migrate(&c).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO keyword_terms(uuid, name, created, revision, modified)
|
||||
VALUES ('a', 'Iceland', 0, 1, 1), ('b', 'Iceland', 0, 1, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let n: i64 = c
|
||||
.query_row("SELECT count(*) FROM keyword_terms", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stems_ignore_directories_containing_dots() {
|
||||
assert_eq!(stem_of("2026.08/IMG_1.CR2"), "IMG_1");
|
||||
assert_eq!(stem_of("IMG_1.CR2"), "IMG_1");
|
||||
assert_eq!(stem_of("noextension"), "noextension");
|
||||
// A dotfile is all stem, not an empty name with an extension.
|
||||
assert_eq!(stem_of(".hidden"), ".hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_creates_schema_at_current_version() {
|
||||
let c = mem();
|
||||
assert_eq!(migrate(&c).unwrap(), 0);
|
||||
let v: i64 = c
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_is_idempotent() {
|
||||
let c = mem();
|
||||
migrate(&c).unwrap();
|
||||
// Re-running must not error or duplicate anything — NFR-R5 requires
|
||||
// idempotency on retry, since a migration can be interrupted.
|
||||
assert_eq!(migrate(&c).unwrap(), SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_catalog_from_a_newer_build() {
|
||||
let c = mem();
|
||||
migrate(&c).unwrap();
|
||||
c.pragma_update(None, "user_version", SCHEMA_VERSION + 1)
|
||||
.unwrap();
|
||||
// Opening it read-write would corrupt data this build cannot
|
||||
// represent. Refusing is the specified behaviour (NFR-R5).
|
||||
assert!(matches!(
|
||||
migrate(&c),
|
||||
Err(CatalogError::SchemaTooNew { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_keys_cascade_from_root_to_image() {
|
||||
let c = mem();
|
||||
migrate(&c).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'test')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (1, 1, 'a.CR3', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute("DELETE FROM roots WHERE id = 1", []).unwrap();
|
||||
|
||||
let n: i64 = c
|
||||
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 0, "images must not outlive their root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_uniqueness_coalesces_rather_than_duplicating() {
|
||||
let c = mem();
|
||||
migrate(&c).unwrap();
|
||||
for _ in 0..5 {
|
||||
c.execute(
|
||||
"INSERT INTO jobs(kind, subject_id, priority) VALUES (1, 42, 0)
|
||||
ON CONFLICT(kind, subject_id)
|
||||
DO UPDATE SET priority = max(priority, excluded.priority)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let n: i64 = c
|
||||
.query_row("SELECT count(*) FROM jobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 1, "five enqueues of the same work is one job");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! TRACES: FR-CAT-7 | FR-NC-9 | NFR-R1
|
||||
//! Preparing the catalog file for upload, and taking in a remote one.
|
||||
//!
|
||||
//! # The hazard this module exists to handle
|
||||
//!
|
||||
//! A WAL-mode SQLite database is not one file. Committed transactions can live
|
||||
//! in `catalog.sqlite-wal` with the main file lagging behind, so copying
|
||||
//! `catalog.sqlite` alone uploads a **torn snapshot**: internally consistent as
|
||||
//! of some older point, missing everything since. Worse, a naive copy taken
|
||||
//! while a writer is mid-transaction can be structurally corrupt.
|
||||
//!
|
||||
//! So an upload never copies the live file. It runs a TRUNCATE checkpoint to
|
||||
//! fold the WAL back into the main file, then uses SQLite's own backup API to
|
||||
//! take a consistent snapshot — which serialises correctly against concurrent
|
||||
//! writers rather than racing them.
|
||||
//!
|
||||
//! # What is actually synced
|
||||
//!
|
||||
//! Only the *user's judgements about their library* merge: collections, and the
|
||||
//! keyword vocabulary with its assignments (see [`crate::merge`]). The rest of
|
||||
//! the catalog is a *local index* of *local* storage — folder mtimes, cache
|
||||
//! paths, job rows — and copying another device's version of those in would be
|
||||
//! actively wrong. The remote file is read for those two and then discarded.
|
||||
//!
|
||||
//! This is why the catalog remains disposable in the ARCH §6.12 sense: nothing
|
||||
//! here makes the local database authoritative for anything a rebuild could
|
||||
//! not recover.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::CatalogError;
|
||||
use crate::merge::{self, MergeReport};
|
||||
|
||||
/// Schema name the downloaded remote catalog is attached under.
|
||||
const REMOTE_SCHEMA: &str = "remote_cat";
|
||||
|
||||
/// Fold the WAL into the main database file.
|
||||
///
|
||||
/// TRUNCATE rather than PASSIVE: passive checkpointing gives up when a reader
|
||||
/// holds the WAL open, which would leave recent commits out of the snapshot
|
||||
/// without saying so.
|
||||
pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> {
|
||||
conn.pragma_update(None, "wal_checkpoint", "TRUNCATE")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a consistent snapshot of the catalog to `dest`, ready to upload.
|
||||
///
|
||||
/// Uses the backup API rather than a filesystem copy so the snapshot is
|
||||
/// coherent even with writers active. Callers should still prefer a quiet
|
||||
/// moment — this competes with background jobs for the write lock.
|
||||
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
|
||||
checkpoint(conn)?;
|
||||
|
||||
let mut out = Connection::open(dest)?;
|
||||
let backup = rusqlite::backup::Backup::new(conn, &mut out)?;
|
||||
// SQLite's own "copy everything" sentinel is -1, but rusqlite asserts a
|
||||
// positive page count, so ask for more pages than a catalog will ever
|
||||
// have. The effect is the same: one step, no interleaved writers, no
|
||||
// progress callback. A 50k-image catalog is tens of megabytes.
|
||||
backup.run_to_completion(i32::MAX, std::time::Duration::ZERO, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a downloaded remote catalog is worth merging.
|
||||
///
|
||||
/// Cheap guard before attaching: a remote written by a newer build may contain
|
||||
/// tables and columns this one cannot read, and attempting the merge would
|
||||
/// fail mid-transaction rather than declining cleanly.
|
||||
pub fn remote_is_mergeable(remote: &Path) -> Result<bool, CatalogError> {
|
||||
let conn = Connection::open_with_flags(
|
||||
remote,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)?;
|
||||
let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
Ok(v <= crate::schema::SCHEMA_VERSION)
|
||||
}
|
||||
|
||||
/// Attach a downloaded remote catalog, merge its collections, detach.
|
||||
///
|
||||
/// The remote file is opened **read-only** — this device never writes to
|
||||
/// another device's catalog, it only reads collections out of it.
|
||||
pub fn merge_remote(conn: &Connection, remote: &Path) -> Result<MergeReport, CatalogError> {
|
||||
if !remote_is_mergeable(remote)? {
|
||||
return Err(CatalogError::SchemaTooNew {
|
||||
found: -1,
|
||||
supported: crate::schema::SCHEMA_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
// Path binds as a parameter; ATTACH accepts one, so a path containing a
|
||||
// quote cannot break out into SQL.
|
||||
conn.execute(
|
||||
&format!("ATTACH DATABASE ?1 AS {REMOTE_SCHEMA}"),
|
||||
[remote.to_string_lossy().as_ref()],
|
||||
)?;
|
||||
|
||||
let result = merge::merge_all(conn);
|
||||
|
||||
// Detach even if the merge failed, or the next attempt errors with
|
||||
// "database remote_cat is already in use".
|
||||
let detach = conn.execute(&format!("DETACH DATABASE {REMOTE_SCHEMA}"), []);
|
||||
if let Err(e) = detach {
|
||||
log::warn!("failed to detach remote catalog: {e}");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Where the catalog snapshot and the downloaded remote live.
|
||||
///
|
||||
/// Both are transient working files, not the catalog itself, so they belong in
|
||||
/// the cache directory rather than beside the live database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncPaths {
|
||||
pub upload_snapshot: PathBuf,
|
||||
pub downloaded_remote: PathBuf,
|
||||
}
|
||||
|
||||
impl SyncPaths {
|
||||
pub fn in_dir(cache_dir: &Path) -> Self {
|
||||
SyncPaths {
|
||||
upload_snapshot: cache_dir.join("catalog-upload.sqlite"),
|
||||
downloaded_remote: cache_dir.join("catalog-remote.sqlite"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::schema;
|
||||
|
||||
fn seeded(path: &Path) -> Connection {
|
||||
let c = Connection::open(path).unwrap();
|
||||
schema::configure(&c).unwrap();
|
||||
schema::migrate(&c).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_captures_committed_data() {
|
||||
let dir = tempdir();
|
||||
let live = dir.join("catalog.sqlite");
|
||||
let snap = dir.join("snap.sqlite");
|
||||
|
||||
let c = seeded(&live);
|
||||
c.execute(
|
||||
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
|
||||
VALUES ('u1', 'Iceland', 0, 0, 1, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
snapshot_for_upload(&c, &snap).unwrap();
|
||||
|
||||
// The snapshot must hold the row even though it was written after the
|
||||
// database was created — the torn-file failure this guards against.
|
||||
let s = Connection::open(&snap).unwrap();
|
||||
let name: String = s
|
||||
.query_row("SELECT name FROM collections", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(name, "Iceland");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_from_a_newer_build_is_declined_not_attempted() {
|
||||
let dir = tempdir();
|
||||
let remote = dir.join("remote.sqlite");
|
||||
let r = seeded(&remote);
|
||||
r.pragma_update(None, "user_version", schema::SCHEMA_VERSION + 1)
|
||||
.unwrap();
|
||||
drop(r);
|
||||
|
||||
assert!(!remote_is_mergeable(&remote).unwrap());
|
||||
|
||||
let local = seeded(&dir.join("local.sqlite"));
|
||||
assert!(matches!(
|
||||
merge_remote(&local, &remote),
|
||||
Err(CatalogError::SchemaTooNew { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_remote_round_trips_a_collection() {
|
||||
let dir = tempdir();
|
||||
let remote_path = dir.join("remote.sqlite");
|
||||
{
|
||||
let r = seeded(&remote_path);
|
||||
r.execute(
|
||||
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
|
||||
VALUES ('u-remote', 'Portugal', 0, 0, 1, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
checkpoint(&r).unwrap();
|
||||
}
|
||||
|
||||
let local = seeded(&dir.join("local.sqlite"));
|
||||
local
|
||||
.execute(
|
||||
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
|
||||
VALUES ('u-local', 'Iceland', 0, 0, 1, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = merge_remote(&local, &remote_path).unwrap();
|
||||
assert_eq!(report.inserted, 1);
|
||||
|
||||
let n: i64 = local
|
||||
.query_row("SELECT count(*) FROM collections", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_remote_can_be_merged_twice_without_attach_conflict() {
|
||||
// Detach must happen even on the failure path, or the second attempt
|
||||
// errors with "database remote_cat is already in use".
|
||||
let dir = tempdir();
|
||||
let remote_path = dir.join("remote.sqlite");
|
||||
{
|
||||
let r = seeded(&remote_path);
|
||||
r.execute(
|
||||
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
|
||||
VALUES ('u-remote', 'Portugal', 0, 0, 1, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
checkpoint(&r).unwrap();
|
||||
}
|
||||
let local = seeded(&dir.join("local.sqlite"));
|
||||
|
||||
merge_remote(&local, &remote_path).unwrap();
|
||||
let second = merge_remote(&local, &remote_path).unwrap();
|
||||
assert!(!second.local_changed());
|
||||
}
|
||||
|
||||
/// A scratch directory that cleans up with the test.
|
||||
fn tempdir() -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"dr-catalog-test-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
base
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
//! TRACES: FR-CAT-15 | NFR-R2
|
||||
//! Soft delete, restore, and the permanent delete that follows.
|
||||
//!
|
||||
//! # Why the trash is a folder and not a flag
|
||||
//!
|
||||
//! The catalog is a *rebuildable index* (ARCH §6.12): delete `catalog.sqlite`
|
||||
//! and it is reconstructed by rescanning sources. A trash implemented as a
|
||||
//! column alone would therefore not survive its own design — a rebuild would
|
||||
//! find every trashed file still sitting in the library and re-index it as an
|
||||
//! ordinary photograph, silently undoing every delete the user had made.
|
||||
//!
|
||||
//! So a soft delete **moves the file** into `.darkroom-trash/` under the library
|
||||
//! root, and the catalog merely records that this happened. The folder is the
|
||||
//! durable fact; the row is the convenience. Recovering by hand needs no
|
||||
//! DarkRoom at all, which is the property that matters when the thing being
|
||||
//! risked is a photograph.
|
||||
//!
|
||||
//! `dr_sync::scan::is_excluded` keeps the scanner out of that folder. Without
|
||||
//! it the next scan re-indexes the trash and the delete comes undone — the two
|
||||
//! halves are one mechanism and neither works alone.
|
||||
//!
|
||||
//! # The two steps
|
||||
//!
|
||||
//! **Soft** ([`trash`]) — `MOVE` to the trash folder, record `trashed_at` and
|
||||
//! the path it came from. Reversible by [`restore`], which is why the original
|
||||
//! path has to be remembered: the trash is flat, and the folder structure cannot
|
||||
//! be recovered from the trashed name.
|
||||
//!
|
||||
//! **Hard** ([`purge`]) — `DELETE` the file, then delete the row. Irreversible
|
||||
//! from DarkRoom's side, though the server's own trashbin may still hold it.
|
||||
//! Ordered file-first deliberately: see [`purge_order`].
|
||||
//!
|
||||
//! # What this module does not do
|
||||
//!
|
||||
//! It performs no I/O. Every function here records or reads catalog state, and
|
||||
//! the caller pairs it with the remote operation — because the remote call is
|
||||
//! async and the catalog is not, and because the *order* of the two is a
|
||||
//! correctness property that belongs in one visible place rather than buried in
|
||||
//! a transaction.
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
|
||||
use dr_types::ImageId;
|
||||
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// Directory holding soft-deleted images, under the library root.
|
||||
///
|
||||
/// The same constant `dr_sync::scan` excludes. Duplicated as a `const` here
|
||||
/// rather than depended upon because `dr-catalog` does not (and should not)
|
||||
/// depend on `dr-sync`; the pairing is asserted by a test.
|
||||
pub const TRASH_DIR: &str = ".darkroom-trash";
|
||||
|
||||
/// One trashed image, as the trash view lists it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TrashedImage {
|
||||
pub image_id: ImageId,
|
||||
/// Where the file is *now* — inside the trash folder.
|
||||
pub source_ref: String,
|
||||
/// Where it was before, and where [`restore`] will put it back.
|
||||
pub trashed_from: String,
|
||||
/// UTC seconds when it was trashed.
|
||||
pub trashed_at: i64,
|
||||
/// `oc:fileid`, preserved across the move. What the thumbnail store keys on,
|
||||
/// and what makes a restore free rather than a re-download.
|
||||
pub file_id: Option<u64>,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// The path a soft-deleted image should be moved to.
|
||||
///
|
||||
/// Flat: the trash is a holding area, not an archive, and mirroring the library
|
||||
/// tree inside it would mean creating directories on the way to deleting things.
|
||||
/// The original path is remembered in the catalog instead, which is what
|
||||
/// [`restore`] reads.
|
||||
///
|
||||
/// **Collisions are resolved rather than allowed to overwrite.** Two files named
|
||||
/// `IMG_0001.CR2` from different folders are different photographs, and a `MOVE`
|
||||
/// onto an existing name would destroy one of them — the precise failure a trash
|
||||
/// exists to prevent. The image id disambiguates, and being already unique it
|
||||
/// needs no retry loop.
|
||||
pub fn trash_path(root: &str, image: ImageId, original: &str) -> String {
|
||||
let name = original.rsplit(['/', ':']).next().unwrap_or(original);
|
||||
let prefix = if root.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{root}/")
|
||||
};
|
||||
format!("{prefix}{TRASH_DIR}/{}-{name}", image.0)
|
||||
}
|
||||
|
||||
/// Where a trashed image goes back to.
|
||||
///
|
||||
/// The stored original path, verbatim. Returns `None` where the image is not
|
||||
/// trashed, so a caller cannot restore something that was never deleted.
|
||||
pub fn restore_path(conn: &Connection, image: ImageId) -> Result<Option<String>, CatalogError> {
|
||||
let path: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT trashed_from FROM images
|
||||
WHERE id = ?1 AND trashed_at IS NOT NULL",
|
||||
[image.0 as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten();
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Record that images have been moved to the trash.
|
||||
///
|
||||
/// Call **after** the move succeeds. Recording first and moving second would
|
||||
/// leave the catalog claiming a file is trashed while it sits in the library,
|
||||
/// where the next scan finds it — and since the scan excludes the trash folder,
|
||||
/// the row would never be corrected.
|
||||
///
|
||||
/// `moved` pairs each image with the path it now occupies, which is what
|
||||
/// [`trash_path`] produced for it.
|
||||
///
|
||||
/// Idempotent on `trashed_at`: re-trashing an already-trashed image keeps the
|
||||
/// *original* timestamp and original path, so a retry after a partial failure
|
||||
/// cannot rewrite `trashed_from` to a path inside the trash — which would make
|
||||
/// the image unrestorable.
|
||||
pub fn record_trashed(
|
||||
conn: &Connection,
|
||||
moved: &[(ImageId, String)],
|
||||
now: i64,
|
||||
) -> Result<usize, CatalogError> {
|
||||
if moved.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut n = 0;
|
||||
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"UPDATE images
|
||||
SET trashed_from = CASE
|
||||
WHEN trashed_at IS NULL THEN source_ref
|
||||
ELSE trashed_from
|
||||
END,
|
||||
source_ref = ?2,
|
||||
trashed_at = coalesce(trashed_at, ?3)
|
||||
WHERE id = ?1",
|
||||
)?;
|
||||
for (image, path) in moved {
|
||||
n += stmt.execute(rusqlite::params![image.0 as i64, path, now])?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Record that images have been moved back out of the trash.
|
||||
///
|
||||
/// Call after the move succeeds, for the same reason as [`record_trashed`].
|
||||
/// Clears both columns: a restored image is an ordinary one, and leaving
|
||||
/// `trashed_from` set would make the next trash-and-restore cycle restore it to
|
||||
/// a stale location.
|
||||
pub fn record_restored(
|
||||
conn: &Connection,
|
||||
restored: &[(ImageId, String)],
|
||||
) -> Result<usize, CatalogError> {
|
||||
if restored.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut n = 0;
|
||||
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"UPDATE images
|
||||
SET source_ref = ?2, trashed_at = NULL, trashed_from = NULL
|
||||
WHERE id = ?1 AND trashed_at IS NOT NULL",
|
||||
)?;
|
||||
for (image, path) in restored {
|
||||
n += stmt.execute(rusqlite::params![image.0 as i64, path])?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Forget images whose files have been permanently deleted.
|
||||
///
|
||||
/// Call **after** the remote delete succeeds — see [`purge_order`].
|
||||
///
|
||||
/// Deletes the catalog rows outright rather than tombstoning them. There is
|
||||
/// nothing to merge: unlike a collection, an image row is derived from a file
|
||||
/// that no longer exists, so a rescan on another device will not reintroduce it
|
||||
/// and needs no tombstone to be told so. `ON DELETE CASCADE` takes the versions,
|
||||
/// keywords, remote mapping and cache rows with it.
|
||||
///
|
||||
/// Returns how many rows went.
|
||||
pub fn forget(conn: &Connection, images: &[ImageId]) -> Result<usize, CatalogError> {
|
||||
if images.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut n = 0;
|
||||
{
|
||||
let mut stmt = tx.prepare("DELETE FROM images WHERE id = ?1")?;
|
||||
for image in images {
|
||||
n += stmt.execute([image.0 as i64])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Why the file is deleted before the row.
|
||||
///
|
||||
/// Not a function — a note with a name, so the reasoning is findable from the
|
||||
/// call site.
|
||||
///
|
||||
/// **File first, then the row.** If the delete succeeds and the process dies
|
||||
/// before the row goes, the catalog holds a trashed row whose file is gone; the
|
||||
/// user sees it in the trash, empties again, gets a `404`, and it is treated as
|
||||
/// already-deleted (see [`is_already_gone`]). Recoverable, and visible.
|
||||
///
|
||||
/// The other order loses the file silently. Dropping the row first and dying
|
||||
/// before the delete leaves an orphan in `.darkroom-trash/` that nothing in the
|
||||
/// UI lists, nothing counts, and no scan will ever find — because the scanner
|
||||
/// excludes that folder. It consumes quota forever and the user has no way to
|
||||
/// learn it is there.
|
||||
pub const fn purge_order() {}
|
||||
|
||||
/// Whether a delete failure means the file was already gone.
|
||||
///
|
||||
/// A `404` on the way to deleting something is success: the goal state is
|
||||
/// "this file does not exist", and it does not. Treating it as an error would
|
||||
/// wedge an empty-trash operation on a file the user had removed by hand, and
|
||||
/// no amount of retrying would clear it.
|
||||
pub fn is_already_gone(status: Option<u16>) -> bool {
|
||||
matches!(status, Some(404) | Some(410))
|
||||
}
|
||||
|
||||
/// List what is in the trash, newest first.
|
||||
///
|
||||
/// Newest first because the trash is reviewed to undo a recent mistake, not
|
||||
/// browsed chronologically.
|
||||
pub fn list(conn: &Connection, limit: usize) -> Result<Vec<TrashedImage>, CatalogError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT i.id, i.source_ref, i.trashed_from, i.trashed_at, r.file_id, i.file_size
|
||||
FROM images i
|
||||
LEFT JOIN remote r ON r.image_id = i.id
|
||||
WHERE i.trashed_at IS NOT NULL
|
||||
ORDER BY i.trashed_at DESC, i.id DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([limit as i64], |r| {
|
||||
let source_ref: String = r.get(1)?;
|
||||
Ok(TrashedImage {
|
||||
image_id: ImageId(r.get::<_, i64>(0)? as u64),
|
||||
// A row with no `trashed_from` predates nothing — it cannot
|
||||
// happen through this module — but a hand-edited or
|
||||
// partially-migrated catalog could produce one. Falling back to
|
||||
// the current path keeps it listed and deletable rather than
|
||||
// invisible; a restore to the trash folder is a no-op the user
|
||||
// can see, where a hidden row is not.
|
||||
trashed_from: r
|
||||
.get::<_, Option<String>>(2)?
|
||||
.unwrap_or_else(|| source_ref.clone()),
|
||||
source_ref,
|
||||
trashed_at: r.get(3)?,
|
||||
file_id: r.get::<_, Option<i64>>(4)?.map(|v| v as u64),
|
||||
size: r.get::<_, Option<i64>>(5)?.unwrap_or(0) as u64,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Every trashed image id, for emptying the whole trash.
|
||||
///
|
||||
/// Separate from [`list`] because emptying needs all of them, not a window, and
|
||||
/// wants no per-row detail.
|
||||
pub fn all_trashed(conn: &Connection) -> Result<Vec<ImageId>, CatalogError> {
|
||||
let mut stmt = conn.prepare("SELECT id FROM images WHERE trashed_at IS NOT NULL")?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// How many images are in the trash, and how many bytes they hold.
|
||||
///
|
||||
/// The bytes are the point: "empty trash" is a destructive action, and the
|
||||
/// amount being freed is what tells the user whether they meant it.
|
||||
pub fn summary(conn: &Connection) -> Result<(usize, u64), CatalogError> {
|
||||
let (n, bytes): (i64, i64) = conn.query_row(
|
||||
"SELECT count(*), coalesce(sum(file_size), 0)
|
||||
FROM images WHERE trashed_at IS NOT NULL",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)?;
|
||||
Ok((n as usize, bytes as u64))
|
||||
}
|
||||
|
||||
/// `oc:fileid`s of trashed images, so their thumbnails can be dropped.
|
||||
///
|
||||
/// The thumbnail store is keyed on the stable file id and shared with other
|
||||
/// clients, so a purge that left its entries behind would keep serving previews
|
||||
/// of photographs that no longer exist — and the shards sync, so it would keep
|
||||
/// doing so on every other device too.
|
||||
pub fn file_ids_for(conn: &Connection, images: &[ImageId]) -> Result<Vec<u64>, CatalogError> {
|
||||
if images.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let placeholders = std::iter::repeat_n("?", images.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!("SELECT file_id FROM remote WHERE image_id IN ({placeholders})");
|
||||
let params: Vec<rusqlite::types::Value> = images
|
||||
.iter()
|
||||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||||
.collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok(r.get::<_, i64>(0)? as u64)
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Catalog;
|
||||
|
||||
fn seeded() -> Catalog {
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'PhotosRaw')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
for i in 1..=4i64 {
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, file_size, added_at)
|
||||
VALUES (?1, 1, ?2, ?3, 0)",
|
||||
rusqlite::params![i, format!("PhotosRaw/2019/IMG_{i:04}.CR2"), 30_000_000 * i],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
|
||||
rusqlite::params![i, 1000 + i],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
cat
|
||||
}
|
||||
|
||||
fn img(i: u64) -> ImageId {
|
||||
ImageId(i)
|
||||
}
|
||||
|
||||
/// Trash one image the way the UI does: compute the path, then record.
|
||||
fn do_trash(cat: &Catalog, i: u64, now: i64) -> String {
|
||||
let c = cat.connection();
|
||||
let original: String = c
|
||||
.query_row(
|
||||
"SELECT source_ref FROM images WHERE id = ?1",
|
||||
[i as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let to = trash_path("PhotosRaw", img(i), &original);
|
||||
record_trashed(c, &[(img(i), to.clone())], now).unwrap();
|
||||
to
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_trash_directory_matches_the_one_the_scanner_excludes() {
|
||||
// These are two constants in two crates that must agree, or the scan
|
||||
// re-indexes the trash and every soft delete comes undone.
|
||||
assert_eq!(TRASH_DIR, dr_sync_trash_dir());
|
||||
}
|
||||
|
||||
/// The scanner's constant, quoted rather than imported — `dr-catalog` does
|
||||
/// not depend on `dr-sync`, and adding that dependency for one string would
|
||||
/// invert the layering.
|
||||
fn dr_sync_trash_dir() -> &'static str {
|
||||
".darkroom-trash"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trashing_moves_the_path_and_remembers_where_it_came_from() {
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
do_trash(&cat, 1, 5_000);
|
||||
|
||||
let (source, from, at): (String, String, i64) = c
|
||||
.query_row(
|
||||
"SELECT source_ref, trashed_from, trashed_at FROM images WHERE id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// `source_ref` follows the bytes: this is where a fetch must now look.
|
||||
assert!(source.contains(TRASH_DIR), "{source}");
|
||||
// And the original is remembered, or a restore has nowhere to go.
|
||||
assert_eq!(from, "PhotosRaw/2019/IMG_0001.CR2");
|
||||
assert_eq!(at, 5_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_trash_path_keeps_the_original_filename_recognisable() {
|
||||
// The user reviewing the trash needs to recognise the photograph; an
|
||||
// opaque id alone would make the list unreadable.
|
||||
let p = trash_path("PhotosRaw", img(7), "PhotosRaw/2019/IMG_0042.CR2");
|
||||
assert!(p.ends_with("IMG_0042.CR2"), "{p}");
|
||||
assert!(p.starts_with("PhotosRaw/.darkroom-trash/"), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_files_with_the_same_name_do_not_collide_in_the_trash() {
|
||||
// The failure a trash exists to prevent: a MOVE onto an existing name
|
||||
// destroys one of two different photographs.
|
||||
let a = trash_path("PhotosRaw", img(1), "PhotosRaw/2019/IMG_0001.CR2");
|
||||
let b = trash_path("PhotosRaw", img(2), "PhotosRaw/2024/IMG_0001.CR2");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_account_root_yields_no_leading_slash() {
|
||||
// The root is empty when the library is the whole account; a path
|
||||
// beginning "/" would resolve differently on the server.
|
||||
let p = trash_path("", img(3), "2019/IMG_0003.CR2");
|
||||
assert_eq!(p, ".darkroom-trash/3-IMG_0003.CR2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_puts_the_original_path_back_and_clears_the_flag() {
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
do_trash(&cat, 1, 5_000);
|
||||
|
||||
let back = restore_path(c, img(1))
|
||||
.unwrap()
|
||||
.expect("knows where it came from");
|
||||
assert_eq!(back, "PhotosRaw/2019/IMG_0001.CR2");
|
||||
|
||||
record_restored(c, &[(img(1), back.clone())]).unwrap();
|
||||
|
||||
let (source, at): (String, Option<i64>) = c
|
||||
.query_row(
|
||||
"SELECT source_ref, trashed_at FROM images WHERE id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(source, back);
|
||||
assert_eq!(at, None, "a restored image is an ordinary one");
|
||||
assert!(restore_path(c, img(1)).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trash_restore_trash_cycle_restores_to_the_right_place_twice() {
|
||||
// If `trashed_from` were not cleared on restore, the second trash would
|
||||
// record a stale origin and the second restore would put the file
|
||||
// somewhere it never was.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
|
||||
do_trash(&cat, 1, 1_000);
|
||||
let first = restore_path(c, img(1)).unwrap().unwrap();
|
||||
record_restored(c, &[(img(1), first.clone())]).unwrap();
|
||||
|
||||
do_trash(&cat, 1, 2_000);
|
||||
let second = restore_path(c, img(1)).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"the origin is the library path, not the trash"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_trashing_does_not_overwrite_the_original_path() {
|
||||
// A retry after a partial failure must not record a trash-folder path as
|
||||
// the origin — that makes the image unrestorable.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let to = do_trash(&cat, 1, 1_000);
|
||||
// Second attempt, as a retry would do.
|
||||
record_trashed(c, &[(img(1), to)], 9_999).unwrap();
|
||||
|
||||
let (from, at): (String, i64) = c
|
||||
.query_row(
|
||||
"SELECT trashed_from, trashed_at FROM images WHERE id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(from, "PhotosRaw/2019/IMG_0001.CR2");
|
||||
assert_eq!(at, 1_000, "the original timestamp survives a retry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_something_that_was_never_trashed_does_nothing() {
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
assert!(restore_path(c, img(2)).unwrap().is_none());
|
||||
assert_eq!(
|
||||
record_restored(c, &[(img(2), "elsewhere".into())]).unwrap(),
|
||||
0
|
||||
);
|
||||
// And its path is untouched.
|
||||
let source: String = c
|
||||
.query_row("SELECT source_ref FROM images WHERE id = 2", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(source, "PhotosRaw/2019/IMG_0002.CR2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_trash_lists_newest_first() {
|
||||
// Reviewed to undo a recent mistake, not browsed chronologically.
|
||||
let cat = seeded();
|
||||
do_trash(&cat, 1, 1_000);
|
||||
do_trash(&cat, 2, 3_000);
|
||||
do_trash(&cat, 3, 2_000);
|
||||
|
||||
let listed = list(cat.connection(), 100).unwrap();
|
||||
let order: Vec<u64> = listed.iter().map(|t| t.image_id.0).collect();
|
||||
assert_eq!(order, vec![2, 3, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_trash_list_carries_the_file_id_a_restore_needs() {
|
||||
// Without it a restore cannot find the thumbnail it already has, and
|
||||
// re-downloads a preview it is holding.
|
||||
let cat = seeded();
|
||||
do_trash(&cat, 1, 1_000);
|
||||
let listed = list(cat.connection(), 10).unwrap();
|
||||
assert_eq!(listed[0].file_id, Some(1001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_reports_what_emptying_would_free() {
|
||||
// "Empty trash" is destructive; the size is what tells the user whether
|
||||
// they meant it.
|
||||
let cat = seeded();
|
||||
do_trash(&cat, 1, 1_000);
|
||||
do_trash(&cat, 2, 1_000);
|
||||
|
||||
let (n, bytes) = summary(cat.connection()).unwrap();
|
||||
assert_eq!(n, 2);
|
||||
assert_eq!(bytes, 30_000_000 + 60_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_trash_summarises_as_zero_rather_than_erroring() {
|
||||
let cat = seeded();
|
||||
assert_eq!(summary(cat.connection()).unwrap(), (0, 0));
|
||||
assert!(all_trashed(cat.connection()).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purging_removes_the_row_and_everything_hanging_off_it() {
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
do_trash(&cat, 1, 1_000);
|
||||
|
||||
assert_eq!(forget(c, &[img(1)]).unwrap(), 1);
|
||||
|
||||
let n: i64 = c
|
||||
.query_row("SELECT count(*) FROM images WHERE id = 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 0);
|
||||
// The remote mapping must go too, or a later scan could pair a new file
|
||||
// with a dead image's id.
|
||||
let n: i64 = c
|
||||
.query_row("SELECT count(*) FROM remote WHERE image_id = 1", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(n, 0, "cascaded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purging_leaves_untrashed_images_alone() {
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
do_trash(&cat, 1, 1_000);
|
||||
forget(c, &all_trashed(c).unwrap()).unwrap();
|
||||
|
||||
let n: i64 = c
|
||||
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 3, "only the trashed one went");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_ids_are_collected_so_thumbnails_can_be_dropped() {
|
||||
// The shards sync to the server; a purge that left them would serve
|
||||
// previews of deleted photographs on every device.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
do_trash(&cat, 1, 1_000);
|
||||
do_trash(&cat, 2, 1_000);
|
||||
|
||||
let mut ids = file_ids_for(c, &[img(1), img(2)]).unwrap();
|
||||
ids.sort_unstable();
|
||||
assert_eq!(ids, vec![1001, 1002]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_counts_as_already_deleted() {
|
||||
// Otherwise one file removed by hand wedges every future empty-trash,
|
||||
// and no amount of retrying clears it.
|
||||
assert!(is_already_gone(Some(404)));
|
||||
assert!(is_already_gone(Some(410)));
|
||||
assert!(!is_already_gone(Some(403)), "a permission failure is real");
|
||||
assert!(!is_already_gone(Some(500)));
|
||||
assert!(!is_already_gone(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_batches_are_no_ops_rather_than_errors() {
|
||||
// The UI can reach these with nothing selected.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
assert_eq!(record_trashed(c, &[], 0).unwrap(), 0);
|
||||
assert_eq!(record_restored(c, &[]).unwrap(), 0);
|
||||
assert_eq!(forget(c, &[]).unwrap(), 0);
|
||||
assert!(file_ids_for(c, &[]).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "dr-decode"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
rawler.workspace = true
|
||||
# The camera profile database is data, not code (FR-DEV-3e): a YAML file that
|
||||
# ships with the binary and is superseded by a newer one on disk. serde_norway
|
||||
# is the workspace's YAML crate — the fork still receiving releases — and it is
|
||||
# already in the tree for `dr-pipeline`'s node declarations and `dr-ui`'s style
|
||||
# tokens. Pure Rust, so it costs nothing under the Android NDK.
|
||||
serde = { workspace = true }
|
||||
serde_norway.workspace = true
|
||||
zune-jpeg.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger.workspace = true
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Report the defect map a raw file carries, if it carries one.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p dr-decode --example defects -- IMG_6320.dng photo.cr2
|
||||
//! ```
|
||||
//!
|
||||
//! Exists because whether this is worth building a correction stage for is a
|
||||
//! question about *your files*, not about the specification: DNGs written by
|
||||
//! cameras that map their own sensors carry `OpcodeList1`, conversions from a
|
||||
//! proprietary raw usually do not, and no CR2 or scanner TIFF ever does.
|
||||
//! Rather than guess, point this at the library and see.
|
||||
|
||||
fn main() {
|
||||
let files: Vec<String> = std::env::args().skip(1).collect();
|
||||
if files.is_empty() {
|
||||
eprintln!("usage: defects <raw file>...");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
for path in &files {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
println!("{path}: unreadable — {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let found = dr_decode::defects(&bytes);
|
||||
if found.is_empty() {
|
||||
println!("{path}: no defect map");
|
||||
continue;
|
||||
}
|
||||
|
||||
println!(
|
||||
"{path}: {} bad pixel(s), {} bad line(s)",
|
||||
found.pixels.len(),
|
||||
found.lines.len()
|
||||
);
|
||||
// A handful, so the output stays readable on a sensor reporting
|
||||
// hundreds — the count above is the number that matters.
|
||||
for p in found.pixels.iter().take(8) {
|
||||
println!(" pixel at {},{}", p.x, p.y);
|
||||
}
|
||||
for l in found.lines.iter().take(8) {
|
||||
match l {
|
||||
dr_decode::BadLine::Column(x) => println!(" dead column {x}"),
|
||||
dr_decode::BadLine::Row(y) => println!(" dead row {y}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
fn main() {
|
||||
for p in std::env::args().skip(1) {
|
||||
let Ok(d) = std::fs::read(&p) else { continue };
|
||||
let n = p.rsplit('/').next().unwrap();
|
||||
// Exactly what the sweep sees: the first HEADER_BYTES only.
|
||||
let head = &d[..d.len().min(dr_decode::HEADER_BYTES as usize)];
|
||||
match dr_decode::metadata(head) {
|
||||
Ok(m) => println!(
|
||||
"{n}: header-only at={:?} model={:?}",
|
||||
m.captured_at, m.model
|
||||
),
|
||||
Err(e) => println!("{n}: header-only ERROR {e}"),
|
||||
}
|
||||
match dr_decode::metadata(&d) {
|
||||
Ok(m) => println!("{n}: whole-file at={:?}", m.captured_at),
|
||||
Err(e) => println!("{n}: whole-file ERROR {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Print what `decode` extracts from a RAW file.
|
||||
//!
|
||||
//! A sanity check on the pipeline's inputs: black and white levels, the CFA
|
||||
//! pattern after re-phasing, as-shot white balance, and the camera→sRGB
|
||||
//! matrix. Wrong values here produce a wrong image no shader can fix, so it
|
||||
//! is worth being able to see them directly.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p dr-decode --example rawinfo -- IMG.CR2
|
||||
//! ```
|
||||
|
||||
fn main() {
|
||||
let Some(path) = std::env::args().nth(1) else {
|
||||
eprintln!("usage: rawinfo <file.cr2>");
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
||||
let bytes = std::fs::read(&path).expect("read file");
|
||||
let raw = dr_decode::decode(&bytes).expect("decode");
|
||||
|
||||
println!("file {path}");
|
||||
println!("readout {} × {}", raw.width, raw.height);
|
||||
println!(
|
||||
"crop {} × {} at ({}, {})",
|
||||
raw.crop.width, raw.crop.height, raw.crop.x, raw.crop.y
|
||||
);
|
||||
let (dx, dy) = raw.crop.shifts_cfa_phase();
|
||||
println!(
|
||||
"cfa {:?} (rephased: {dx}, {dy})",
|
||||
raw.cfa_pattern
|
||||
);
|
||||
println!("black {:?}", raw.black_level);
|
||||
println!("white {}", raw.white_level);
|
||||
println!("wb_coeffs {:?}", raw.wb_coeffs);
|
||||
|
||||
match raw.color_matrix {
|
||||
Some(m) => {
|
||||
println!("cam→srgb");
|
||||
for row in m.chunks(3) {
|
||||
println!(
|
||||
" [{:>8.4} {:>8.4} {:>8.4}]",
|
||||
row[0], row[1], row[2]
|
||||
);
|
||||
}
|
||||
// Each row should sum to roughly 1: a neutral camera-space colour
|
||||
// must stay neutral in sRGB. Far from 1 means the normalisation
|
||||
// or the matrix composition is wrong.
|
||||
let sums: Vec<f32> = m.chunks(3).map(|r| r.iter().sum()).collect();
|
||||
println!("row sums {sums:.4?} (≈1.0 each if correct)");
|
||||
}
|
||||
None => println!("cam→srgb none — uncalibrated body"),
|
||||
}
|
||||
|
||||
// Sample the actual data range, which reveals a black-level or bit-depth
|
||||
// mistake faster than any amount of staring at metadata.
|
||||
let (min, max) = raw
|
||||
.data
|
||||
.iter()
|
||||
.fold((u16::MAX, 0u16), |(lo, hi), &v| (lo.min(v), hi.max(v)));
|
||||
println!("sample range {min} … {max}");
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Smoke test against real RAW files.
|
||||
//!
|
||||
//! cargo run -p dr-decode --example smoke -- <file-or-dir>...
|
||||
//!
|
||||
//! Reports, per file, what each entry point costs — which is the whole reason
|
||||
//! they are separate (ARCH §3.2).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.is_empty() {
|
||||
eprintln!("usage: smoke <file-or-dir>...");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
for a in &args {
|
||||
let p = PathBuf::from(a);
|
||||
if p.is_dir() {
|
||||
collect(&p, &mut files);
|
||||
} else {
|
||||
files.push(p);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
files.truncate(8);
|
||||
|
||||
println!(
|
||||
"{:<20} {:>7} {:>8} {:>9} {:>13} {:>9} {:>13}",
|
||||
"file", "size", "meta", "thumb", "thumb dims", "full", "full dims"
|
||||
);
|
||||
println!("{}", "-".repeat(88));
|
||||
|
||||
let (mut ok, mut failed) = (0, 0);
|
||||
for f in &files {
|
||||
match run_one(f) {
|
||||
Ok(line) => {
|
||||
println!("{line}");
|
||||
ok += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{:<22} {e}", truncate(&name(f), 22));
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n{ok} ok, {failed} failed");
|
||||
if failed > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_one(path: &Path) -> Result<String, String> {
|
||||
let size = std::fs::metadata(path).map_err(|e| e.to_string())?.len();
|
||||
|
||||
// The culling path: read only the header region, not the whole file.
|
||||
let probe_bytes =
|
||||
read_prefix(path, dr_decode::PREVIEW_PROBE_BYTES).map_err(|e| e.to_string())?;
|
||||
let t0 = Instant::now();
|
||||
let fmt = dr_decode::probe(&probe_bytes);
|
||||
let meta = dr_decode::metadata(&probe_bytes).ok();
|
||||
let meta_ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let all = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
|
||||
// The culling rung.
|
||||
let t1 = Instant::now();
|
||||
let thumb = dr_decode::extract_preview(&all, dr_decode::PreviewSize::Thumbnail)
|
||||
.map_err(|e| format!("thumb: {e}"))?;
|
||||
let thumb_ms = t1.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
// The full-resolution rung, for comparison.
|
||||
let t2 = Instant::now();
|
||||
let full = dr_decode::extract_preview(&all, dr_decode::PreviewSize::Full)
|
||||
.map_err(|e| format!("full: {e}"))?;
|
||||
let full_ms = t2.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let model = meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.model.clone())
|
||||
.unwrap_or_else(|| "?".into());
|
||||
let budget = if thumb_ms <= 50.0 {
|
||||
""
|
||||
} else {
|
||||
" OVER BUDGET"
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"{:<20} {:>6.1}M {:>6.1}ms {:>7.1}ms {:>7}x{:<5} {:>7.1}ms {:>7}x{:<5} {:?} {}{}",
|
||||
truncate(&name(path), 20),
|
||||
size as f64 / 1e6,
|
||||
meta_ms,
|
||||
thumb_ms,
|
||||
thumb.width,
|
||||
thumb.height,
|
||||
full_ms,
|
||||
full.width,
|
||||
full.height,
|
||||
fmt,
|
||||
model.trim(),
|
||||
budget,
|
||||
))
|
||||
}
|
||||
|
||||
fn read_prefix(path: &Path, n: u64) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let mut f = std::fs::File::open(path)?;
|
||||
let mut buf = vec![0u8; n as usize];
|
||||
let read = f.read(&mut buf)?;
|
||||
buf.truncate(read);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_file() {
|
||||
let ext = p
|
||||
.extension()
|
||||
.map(|s| s.to_string_lossy().to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
if dr_types::Format::from_extension(&ext).is_some() {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn name(p: &Path) -> String {
|
||||
p.file_name().unwrap_or_default().to_string_lossy().into()
|
||||
}
|
||||
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
if s.len() <= n {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}…", &s[..n - 1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
# DarkRoom camera base curves (FR-DEV-3e).
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adding a body is editing this file. It is not a code change.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The copy you are reading is compiled into the binary as a floor. At startup
|
||||
# `dr_decode::base_curve::load` also looks for `base_curves.yaml` in:
|
||||
#
|
||||
# 1. $DARKROOM_PROFILES/ (set it while you are tuning)
|
||||
# 2. $XDG_DATA_HOME/darkroom/profiles/
|
||||
# or $HOME/.local/share/darkroom/profiles/
|
||||
#
|
||||
# and uses the first one it finds *whose `version:` is higher than this one's*.
|
||||
# So: bump `version`, drop the file in that directory, restart. A body added
|
||||
# this afternoon renders correctly this afternoon, with no release and no
|
||||
# rebuild — which is what the requirement asks for, and what makes these
|
||||
# contributable under the GPL.
|
||||
#
|
||||
# The version check runs both ways on purpose. A file older than the built-in
|
||||
# copy is ignored with a log line, so upgrading DarkRoom cannot silently lose
|
||||
# curves to a pack somebody downloaded a year ago.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# What the numbers mean
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Five `[x, y]` control points on a monotone spline (Fritsch-Carlson, the same
|
||||
# one the tone curve widget draws). Both axes are **linear**:
|
||||
#
|
||||
# x scene-referred camera RGB after white balance, 1.0 = sensor saturation
|
||||
# y display-referred linear; the sRGB transfer function is applied later,
|
||||
# at the end of the shader, so do not pre-apply a gamma here
|
||||
#
|
||||
# The identity is y = x, and it is what an unrecognised body gets if `default:`
|
||||
# is removed. It is also the wrong answer for almost every photograph: linear
|
||||
# scene data has middle grey at about 13% and a camera JPEG puts it near 18%,
|
||||
# so an uncurved render is roughly half a stop dark through the midtones and
|
||||
# has no highlight rolloff at all.
|
||||
#
|
||||
# A curve that works has three parts, and it is worth naming them because they
|
||||
# are what you are actually tuning:
|
||||
#
|
||||
# the toe the first span, slope near or below 1. Deep shadows stay
|
||||
# deep. Lift it and blacks go milky; crush it and shadow
|
||||
# detail the sensor recorded disappears.
|
||||
# the midtones the middle spans, slope well above 1. This is the contrast
|
||||
# and the brightness people read as "the camera's look".
|
||||
# the shoulder the last span, slope well below 1. Highlights compress
|
||||
# toward white instead of arriving there and clipping. It is
|
||||
# the difference between a rolled-off sky and a white hole.
|
||||
#
|
||||
# Two invariants are enforced in code and tested, so a mistake here fails the
|
||||
# build rather than the photograph: x must strictly increase, y must not
|
||||
# decrease, and everything must lie inside the unit square.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Honesty about these values
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# These are hand-tuned shapes, not measurements. They encode what every camera
|
||||
# JPEG rendering has in common — the toe/midtone/shoulder structure above —
|
||||
# plus each maker's well-known house differences: Canon's gentler shoulder and
|
||||
# warmer-reading midtones, Nikon's slightly higher midtone contrast, Sony's
|
||||
# flatter and more conservative default, Fujifilm's markedly contrastier
|
||||
# Provia-derived rendering.
|
||||
#
|
||||
# FR-DEV-3e's acceptance criterion is subjective comparison against each body's
|
||||
# own JPEG, and meeting it properly needs a frame from that body in front of
|
||||
# you. Where that has not been done, the entry is still much closer to right
|
||||
# than the identity — which is the bar these have to clear, and do.
|
||||
|
||||
version: 1
|
||||
|
||||
# The rendering for a body with no entry of its own.
|
||||
#
|
||||
# **Deliberately not the identity.** The failure this requirement exists to fix
|
||||
# is the flat render, and a conservative curve is far closer to right for every
|
||||
# body than no curve is for any of them. It is gentler than the per-body
|
||||
# entries below — a shallower midtone and an earlier, softer shoulder — because
|
||||
# it has to be safe on a sensor nobody has looked at, and the cost of being too
|
||||
# tame is a photograph that wants a little contrast rather than one that has
|
||||
# lost its highlights.
|
||||
default:
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.043]
|
||||
- [0.13, 0.175]
|
||||
- [0.45, 0.690]
|
||||
- [1.00, 1.000]
|
||||
|
||||
bodies:
|
||||
# Canon. A soft toe and a long, gradual shoulder — the reason Canon files
|
||||
# are described as forgiving in highlights and a little low in contrast
|
||||
# straight out of camera.
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.045]
|
||||
- [0.13, 0.190]
|
||||
- [0.45, 0.720]
|
||||
- [1.00, 1.000]
|
||||
|
||||
- make: Canon
|
||||
model: EOS R6
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.044]
|
||||
- [0.13, 0.195]
|
||||
- [0.45, 0.730]
|
||||
- [1.00, 1.000]
|
||||
|
||||
# Nikon. A slightly deeper toe and more midtone slope than Canon, which is
|
||||
# the "punchier out of camera" difference people describe between the two.
|
||||
- make: Nikon
|
||||
model: Z 6
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.038]
|
||||
- [0.13, 0.200]
|
||||
- [0.46, 0.750]
|
||||
- [1.00, 1.000]
|
||||
|
||||
- make: Nikon
|
||||
model: D750
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.039]
|
||||
- [0.13, 0.198]
|
||||
- [0.46, 0.745]
|
||||
- [1.00, 1.000]
|
||||
|
||||
# Sony. The flattest default of the four, and intentionally so — Sony's own
|
||||
# rendering leaves more headroom than it uses, which is why Sony files are
|
||||
# the ones people describe as needing the most work.
|
||||
- make: Sony
|
||||
model: ILCE-7M3
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.048]
|
||||
- [0.13, 0.185]
|
||||
- [0.44, 0.700]
|
||||
- [1.00, 1.000]
|
||||
|
||||
# Fujifilm. Provia, the default film simulation: a firm toe, the steepest
|
||||
# midtones here, and a hard shoulder. It is the most distinctive rendering of
|
||||
# the four and the one where a flat render looks most obviously wrong.
|
||||
#
|
||||
# This entry does *not* read the in-RAF film simulation tag — that is
|
||||
# FR-DEV-3f, and until it lands every Fujifilm file gets the Provia shape
|
||||
# whatever the camera was set to.
|
||||
- make: Fujifilm
|
||||
model: X-T3
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.045, 0.040]
|
||||
- [0.14, 0.215]
|
||||
- [0.47, 0.775]
|
||||
- [1.00, 1.000]
|
||||
@@ -0,0 +1,763 @@
|
||||
//! TRACES: FR-DEV-3e
|
||||
//! Base curves — the per-body rendering that turns a correct exposure into a
|
||||
//! photograph.
|
||||
//!
|
||||
//! # What this is for
|
||||
//!
|
||||
//! A camera matrix gets the *colours* right and leaves the picture flat. Sensor
|
||||
//! data is scene-referred and very nearly linear; a print, a screen and a
|
||||
//! camera's own JPEG are none of those things. Rendering linear data straight
|
||||
//! out is the dcraw default, and FR-DEV-3e names it precisely: "the flat,
|
||||
//! poor-skin-tone rendering characteristic of dcraw defaults, which is the
|
||||
//! documented reason people abandon darktable in the first hour."
|
||||
//!
|
||||
//! The fix is a tone curve applied as part of *reading* the file rather than as
|
||||
//! an edit — a toe, a steep midtone, and a shoulder that rolls highlights off
|
||||
//! instead of clipping them. Every raw converter has one. Adobe calls it the
|
||||
//! camera profile's tone curve, darktable calls it the base curve, and the name
|
||||
//! here follows darktable's because the placement does too: it runs in camera
|
||||
//! RGB, after white balance and the user's adjustments, immediately before the
|
||||
//! conversion out to a working space.
|
||||
//!
|
||||
//! # Why it is not an edit
|
||||
//!
|
||||
//! It never reaches the sidecar and there is no slider for it, for the same
|
||||
//! reason the EXIF orientation is not an edit (FR-DEV-3h): it is a property of
|
||||
//! the body that took the frame, not of what anyone decided about the frame.
|
||||
//! Sidecars are shared between devices and bodies (FR-NC-9), and one camera's
|
||||
//! rendering must not follow an edit onto another camera's file.
|
||||
//!
|
||||
//! # Why it is data
|
||||
//!
|
||||
//! FR-DEV-3e requires the profile database to be "versioned independently of
|
||||
//! the app binary so bodies and curves can be added without a release — and,
|
||||
//! under D8's GPLv3, contributed by users". So the curves live in
|
||||
//! `profiles/base_curves.yaml`, a file that is compiled in as a floor and
|
||||
//! *overridden* by a copy on disk carrying a higher `version:`. Adding a body
|
||||
//! is adding ten numbers to a YAML file; shipping that body to users is
|
||||
//! publishing the file. Neither is a code change and neither needs a release.
|
||||
//!
|
||||
//! See [`load`] for the search path and [`Curves::body`] for the matching.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// How many control points a base curve has.
|
||||
///
|
||||
/// Five, which is not a coincidence: it is what the tone curve widget uses
|
||||
/// (`dr_pipeline::ops::curve::POINTS`), so the shader evaluates a profile's
|
||||
/// curve and a photographer's curve through exactly the same spline. A profile
|
||||
/// author and a photographer dragging a point mean the same thing by it, and
|
||||
/// the generated shader carries one implementation rather than two that could
|
||||
/// disagree.
|
||||
pub const POINTS: usize = 5;
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// A base curve: five points on a monotone spline through the unit square.
|
||||
///
|
||||
/// `xs` is scene-linear camera RGB, normalised so that 1.0 is the sensor's
|
||||
/// saturation point. `ys` is display-referred linear — *not* gamma-encoded,
|
||||
/// because the sRGB transfer function is applied at the very end of the
|
||||
/// generated shader and applying it twice would wash the image out.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct BaseCurve {
|
||||
pub xs: [f32; POINTS],
|
||||
pub ys: [f32; POINTS],
|
||||
}
|
||||
|
||||
impl BaseCurve {
|
||||
/// The curve that does nothing — the identity diagonal.
|
||||
///
|
||||
/// What an unrecognised body gets if the database carries no default, and
|
||||
/// what a JPEG gets always: an already-rendered image must not be rendered
|
||||
/// a second time.
|
||||
pub const IDENTITY: Self = Self {
|
||||
xs: [0.0, 0.25, 0.5, 0.75, 1.0],
|
||||
ys: [0.0, 0.25, 0.5, 0.75, 1.0],
|
||||
};
|
||||
|
||||
/// Whether this curve would leave the image alone.
|
||||
///
|
||||
/// The shader is told to skip the stage entirely when it would, so an
|
||||
/// unprofiled body costs a branch that is uniform across the dispatch
|
||||
/// rather than a spline evaluation per channel per pixel.
|
||||
pub fn is_identity(&self) -> bool {
|
||||
self.xs
|
||||
.iter()
|
||||
.zip(self.ys.iter())
|
||||
.all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
/// Build from raw pairs, rejecting anything that is not a curve.
|
||||
///
|
||||
/// A profile file is data a user may have edited, so this is the boundary
|
||||
/// where "ten numbers" becomes "a curve": the x coordinates must increase,
|
||||
/// the y coordinates must not decrease, and both must lie in the unit
|
||||
/// square. A non-monotone x sends the spline's span search backwards and
|
||||
/// divides by a negative width; a decreasing y inverts tones locally,
|
||||
/// which reads as a dark halo through smooth gradients rather than as a
|
||||
/// bad profile.
|
||||
///
|
||||
/// Endpoints are not forced to (0,0) and (1,1). A curve that lifts black
|
||||
/// slightly, or that places the shoulder below white, is a legitimate
|
||||
/// rendering choice and several bodies make it.
|
||||
pub fn from_points(points: &[[f32; 2]]) -> Option<Self> {
|
||||
if points.len() != POINTS {
|
||||
return None;
|
||||
}
|
||||
let mut xs = [0.0f32; POINTS];
|
||||
let mut ys = [0.0f32; POINTS];
|
||||
for (i, p) in points.iter().enumerate() {
|
||||
if !p[0].is_finite() || !p[1].is_finite() {
|
||||
return None;
|
||||
}
|
||||
if !(0.0..=1.0).contains(&p[0]) || !(0.0..=1.0).contains(&p[1]) {
|
||||
return None;
|
||||
}
|
||||
xs[i] = p[0];
|
||||
ys[i] = p[1];
|
||||
}
|
||||
for i in 1..POINTS {
|
||||
// Strictly increasing in x — the spline divides by the span width.
|
||||
if xs[i] <= xs[i - 1] {
|
||||
return None;
|
||||
}
|
||||
// Non-decreasing in y. Flat is allowed: a curve that holds a
|
||||
// highlight range at white is clipping deliberately.
|
||||
if ys[i] < ys[i - 1] {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self { xs, ys })
|
||||
}
|
||||
}
|
||||
|
||||
/// One body's entry in the database.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BodyCurve {
|
||||
/// The manufacturer, as the file writes it — "Canon", "NIKON CORPORATION".
|
||||
pub make: String,
|
||||
/// The model, as the file writes it — "EOS 6D", "ILCE-7M3".
|
||||
pub model: String,
|
||||
pub curve: BaseCurve,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The base curve database.
|
||||
///
|
||||
/// Versioned as a whole rather than per body, because that is the unit a user
|
||||
/// downloads and the unit that has to beat the built-in copy. See [`load`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Curves {
|
||||
version: u32,
|
||||
default: Option<BaseCurve>,
|
||||
bodies: Vec<BodyCurve>,
|
||||
}
|
||||
|
||||
impl Curves {
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The curve to render a frame from this body with.
|
||||
///
|
||||
/// Falls back, in order, to the database's `default:` and then to the
|
||||
/// identity. **The default is deliberately not the identity**: an
|
||||
/// unrecognised body rendered flat is the failure this requirement exists
|
||||
/// to prevent, and a gentle, conservative curve is much closer to right for
|
||||
/// every body than no curve is for any of them. A body with its own entry
|
||||
/// gets that instead.
|
||||
///
|
||||
/// # What "this body" has to survive
|
||||
///
|
||||
/// The same camera names itself three ways depending on which program last
|
||||
/// touched the file. A native NEF says make "NIKON CORPORATION", model
|
||||
/// "NIKON Z 6"; rawler's own database cleans that to "Nikon" and "Z 6"; an
|
||||
/// Adobe-converted DNG keeps the uncleaned pair. A database that had to
|
||||
/// spell every variant would go stale the first time a maker changed its
|
||||
/// mind about its own name, so the matching does the folding instead:
|
||||
///
|
||||
/// - Case, punctuation and runs of whitespace are flattened, so
|
||||
/// "ILCE-7M3", "ILCE 7M3" and "ilce-7m3" are one body.
|
||||
/// - The make is compared on its **first word only**. Every maker's
|
||||
/// trailing corporate boilerplate — "CORPORATION", "IMAGING CORP" — is
|
||||
/// noise, and no two camera manufacturers share a first word.
|
||||
/// - The model is tried both as written and with a leading copy of the
|
||||
/// make removed, which is what lets one "Canon"/"EOS 6D" entry cover
|
||||
/// "Canon EOS 6D" as well.
|
||||
pub fn body(&self, make: &str, model: &str) -> BaseCurve {
|
||||
let (make, model) = (make_key(make), normalise(model));
|
||||
// The model with a leading copy of the maker's name removed.
|
||||
let bare = model.strip_prefix(&format!("{make} ")).unwrap_or(&model);
|
||||
|
||||
self.bodies
|
||||
.iter()
|
||||
.find(|b| {
|
||||
let entry_model = normalise(&b.model);
|
||||
make_key(&b.make) == make && (entry_model == model || entry_model == bare)
|
||||
})
|
||||
.map(|b| b.curve)
|
||||
.or(self.default)
|
||||
.unwrap_or(BaseCurve::IDENTITY)
|
||||
}
|
||||
|
||||
/// The database version. Higher wins; see [`load`].
|
||||
pub fn version(&self) -> u32 {
|
||||
self.version
|
||||
}
|
||||
|
||||
/// How many bodies have their own curve, excluding the default.
|
||||
pub fn len(&self) -> usize {
|
||||
self.bodies.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.bodies.is_empty()
|
||||
}
|
||||
|
||||
/// Parse a database from YAML.
|
||||
///
|
||||
/// Entries that are not curves are dropped with a warning rather than
|
||||
/// failing the parse. A user-contributed file with one bad body should
|
||||
/// cost that body's rendering, not every body's — and the alternative is an
|
||||
/// application that will not open a photograph because somebody typed a
|
||||
/// comma.
|
||||
pub fn parse(yaml: &str) -> Result<Self, String> {
|
||||
let file: File = serde_norway::from_str(yaml).map_err(|e| e.to_string())?;
|
||||
|
||||
let default = file.default.and_then(|d| {
|
||||
BaseCurve::from_points(&d.points).or_else(|| {
|
||||
log::warn!("base curves: the default entry is not a monotone curve; ignoring it");
|
||||
None
|
||||
})
|
||||
});
|
||||
|
||||
let bodies = file
|
||||
.bodies
|
||||
.into_iter()
|
||||
.filter_map(|b| match BaseCurve::from_points(&b.points) {
|
||||
Some(curve) => Some(BodyCurve {
|
||||
make: b.make,
|
||||
model: b.model,
|
||||
curve,
|
||||
}),
|
||||
None => {
|
||||
log::warn!(
|
||||
"base curves: {} {} is not a monotone curve; ignoring it",
|
||||
b.make,
|
||||
b.model
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
version: file.version,
|
||||
default,
|
||||
bodies,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The copy that ships inside the binary.
|
||||
///
|
||||
/// A floor, not the answer: [`load`] prefers a newer file on disk. Compiled in
|
||||
/// so that a fresh install with no profile directory — and every Android build,
|
||||
/// where there is no such directory to speak of — still renders properly.
|
||||
const BUILT_IN: &str = include_str!("../profiles/base_curves.yaml");
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The base curve database, loaded once.
|
||||
///
|
||||
/// # The search path, and why it is a version comparison
|
||||
///
|
||||
/// 1. `$DARKROOM_PROFILES`, a directory, when set. The escape hatch: a profile
|
||||
/// author iterating on a curve points this at their working copy and does
|
||||
/// not have to install anything.
|
||||
/// 2. `$XDG_DATA_HOME/darkroom/profiles/`, else `$HOME/.local/share/darkroom/profiles/`.
|
||||
/// The same base directory the catalog uses, chosen there for the same
|
||||
/// reason — it is data, not cache, and must survive a storage sweep.
|
||||
/// 3. The copy compiled into the binary.
|
||||
///
|
||||
/// The first file that parses *and carries a higher `version:` than the
|
||||
/// built-in copy* wins. The version check is the whole mechanism the
|
||||
/// requirement asks for, and it runs in both directions:
|
||||
///
|
||||
/// - A downloaded pack at version 7 supersedes a binary shipping version 3, so
|
||||
/// a body added after the release renders correctly with no release.
|
||||
/// - A stale pack at version 2 does **not** supersede a binary shipping version
|
||||
/// 3, so upgrading the application cannot silently lose curves to a file
|
||||
/// somebody downloaded a year ago and forgot.
|
||||
///
|
||||
/// Failures are warnings, never errors. A malformed profile file must cost the
|
||||
/// user their curves, not their photographs.
|
||||
pub fn load() -> &'static Curves {
|
||||
static LOADED: OnceLock<Curves> = OnceLock::new();
|
||||
LOADED.get_or_init(|| {
|
||||
let built_in = Curves::parse(BUILT_IN).unwrap_or_else(|e| {
|
||||
// Unreachable in a build that ran its tests — `the_shipped_database_parses`
|
||||
// asserts exactly this — but a panic here would mean an
|
||||
// application that cannot open a photograph because of a typo in a
|
||||
// data file, which is never the right trade.
|
||||
log::error!("base curves: the built-in database does not parse: {e}");
|
||||
Curves {
|
||||
version: 0,
|
||||
default: None,
|
||||
bodies: Vec::new(),
|
||||
}
|
||||
});
|
||||
|
||||
choose(built_in, &search_path())
|
||||
})
|
||||
}
|
||||
|
||||
/// The version comparison, separated from where the directories come from.
|
||||
///
|
||||
/// Split out so it can be tested against real files in a real directory
|
||||
/// without the process-wide `OnceLock` and the environment `load` reads. The
|
||||
/// rule this implements is the whole of what FR-DEV-3e asks for, so it is
|
||||
/// worth being able to state it as a test rather than as a comment.
|
||||
fn choose(built_in: Curves, dirs: &[PathBuf]) -> Curves {
|
||||
for dir in dirs {
|
||||
let path = dir.join("base_curves.yaml");
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
match Curves::parse(&text) {
|
||||
Ok(external) if external.version > built_in.version => {
|
||||
log::info!(
|
||||
"base curves: using {} (version {}, {} bodies) over the built-in version {}",
|
||||
path.display(),
|
||||
external.version,
|
||||
external.len(),
|
||||
built_in.version
|
||||
);
|
||||
return external;
|
||||
}
|
||||
Ok(external) => log::info!(
|
||||
"base curves: ignoring {} at version {}; the built-in database is version {}",
|
||||
path.display(),
|
||||
external.version,
|
||||
built_in.version
|
||||
),
|
||||
Err(e) => log::warn!("base curves: {} does not parse: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
built_in
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The curve for a body, from the loaded database.
|
||||
///
|
||||
/// The one call site the decoder needs; everything above is reachable for
|
||||
/// tests and for a future profile editor.
|
||||
pub fn for_body(make: &str, model: &str) -> BaseCurve {
|
||||
load().body(make, model)
|
||||
}
|
||||
|
||||
/// Directories that may hold a `base_curves.yaml`, most specific first.
|
||||
fn search_path() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(explicit) = std::env::var_os("DARKROOM_PROFILES") {
|
||||
dirs.push(PathBuf::from(explicit));
|
||||
}
|
||||
// The same resolution `dr_ui::library::catalog_path` uses, and for the
|
||||
// same reason: this is data a user may have installed, not a cache. It is
|
||||
// duplicated rather than shared because `dr-decode` sits far below the UI
|
||||
// and must not acquire a dependency on it to find a directory.
|
||||
let base = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".local/share")));
|
||||
if let Some(base) = base {
|
||||
dirs.push(base.join("darkroom").join("profiles"));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// A manufacturer's first word, folded.
|
||||
///
|
||||
/// "NIKON CORPORATION", "Nikon" and "nikon" all become `NIKON`. The corporate
|
||||
/// suffixes are not information — they appear or not depending on whether the
|
||||
/// file went through a DNG converter — and no two camera manufacturers share a
|
||||
/// first word, so nothing is lost by dropping them.
|
||||
fn make_key(s: &str) -> String {
|
||||
normalise(s)
|
||||
.split(' ')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Fold a make or model into something two files can agree on.
|
||||
///
|
||||
/// Upper-cased, with every run of non-alphanumeric characters collapsed to one
|
||||
/// space and the ends trimmed, so that "ILCE-7M3", "ILCE 7M3" and "ilce-7m3"
|
||||
/// become one.
|
||||
fn normalise(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut pending_space = false;
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
if pending_space && !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
pending_space = false;
|
||||
out.push(c.to_ascii_uppercase());
|
||||
} else {
|
||||
pending_space = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- The on-disk shape, kept apart from the in-memory one ----------------
|
||||
//
|
||||
// Deliberately separate types. The file is data a user edits and is allowed to
|
||||
// be wrong; `Curves` is a parsed database whose every entry is known to be a
|
||||
// monotone curve. Deriving `Deserialize` on `BaseCurve` directly would delete
|
||||
// that boundary and let an unchecked five-point array reach the shader.
|
||||
//
|
||||
// Unknown fields are **accepted**, which is not laziness. The database is
|
||||
// versioned independently of the binary and moves in both directions: a pack
|
||||
// published after this release may carry keys this build has never heard of —
|
||||
// a hue twist, a look table (FR-DEV-3f) — and it must still deliver its curves
|
||||
// to an older DarkRoom rather than failing to parse and leaving every body
|
||||
// flat. `deny_unknown_fields` would trade that for a diagnostic nobody needs.
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct File {
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
default: Option<Entry>,
|
||||
#[serde(default)]
|
||||
bodies: Vec<BodyEntry>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Entry {
|
||||
points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BodyEntry {
|
||||
make: String,
|
||||
model: String,
|
||||
points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_shipped_database_parses_and_carries_a_default() {
|
||||
// The one test that must never be allowed to fail quietly: `load`
|
||||
// degrades to an empty database rather than panicking, so without this
|
||||
// a typo in the YAML would ship as "every photograph renders flat"
|
||||
// rather than as a build failure.
|
||||
let curves = Curves::parse(BUILT_IN).expect("the shipped database parses");
|
||||
assert!(curves.version() >= 1);
|
||||
assert!(!curves.is_empty(), "the database ships bodies");
|
||||
assert!(
|
||||
!curves.body("Nobody", "Nothing").is_identity(),
|
||||
"an unknown body must still get the default rendering"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_shipped_curve_lifts_the_midtones_and_rolls_the_highlights() {
|
||||
// What makes a base curve a base curve rather than a decoration. If a
|
||||
// shipped curve failed either half it would be a worse rendering than
|
||||
// the flat one it replaced, which is the one outcome forbidden.
|
||||
let curves = Curves::parse(BUILT_IN).expect("parses");
|
||||
let all = curves
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| (format!("{} {}", b.make, b.model), b.curve))
|
||||
.chain(curves.default.map(|c| ("default".to_string(), c)));
|
||||
|
||||
for (name, curve) in all {
|
||||
// The midtone point sits above the diagonal: a linear midtone is
|
||||
// roughly a stop and a half darker than any camera renders it.
|
||||
let mid = 2;
|
||||
assert!(
|
||||
curve.ys[mid] > curve.xs[mid],
|
||||
"{name} does not lift its midtones ({} -> {})",
|
||||
curve.xs[mid],
|
||||
curve.ys[mid]
|
||||
);
|
||||
// And the last span is shallower than the one before it, which is
|
||||
// what a shoulder *is*. Without one the curve clips highlights
|
||||
// harder than the linear rendering did.
|
||||
let slope = |i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]);
|
||||
assert!(
|
||||
slope(POINTS - 2) < slope(POINTS - 3),
|
||||
"{name} has no highlight shoulder"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_that_is_not_monotone_is_refused() {
|
||||
// The profile file is user-editable, so this is a real boundary and
|
||||
// not a formality. A decreasing y inverts tones locally and shows up
|
||||
// as a dark halo in a gradient, which reads as a rendering fault
|
||||
// rather than as a bad profile.
|
||||
assert_eq!(
|
||||
BaseCurve::from_points(&[
|
||||
[0.0, 0.0],
|
||||
[0.25, 0.4],
|
||||
[0.5, 0.3],
|
||||
[0.75, 0.8],
|
||||
[1.0, 1.0]
|
||||
]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_whose_x_does_not_advance_is_refused() {
|
||||
// The spline divides by the span width; a repeated x is a division by
|
||||
// zero in the shader, which is a NaN pixel rather than an error.
|
||||
assert_eq!(
|
||||
BaseCurve::from_points(&[
|
||||
[0.0, 0.0],
|
||||
[0.25, 0.3],
|
||||
[0.25, 0.5],
|
||||
[0.75, 0.8],
|
||||
[1.0, 1.0]
|
||||
]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_of_the_wrong_length_is_refused() {
|
||||
assert_eq!(BaseCurve::from_points(&[[0.0, 0.0], [1.0, 1.0]]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_outside_the_unit_square_are_refused() {
|
||||
// The shader clamps its output at the very end anyway, but a control
|
||||
// point above 1.0 would put the shoulder outside the range the curve
|
||||
// is defined over and silently flatten everything below it.
|
||||
assert_eq!(
|
||||
BaseCurve::from_points(&[
|
||||
[0.0, 0.0],
|
||||
[0.25, 0.3],
|
||||
[0.5, 1.4],
|
||||
[0.75, 1.5],
|
||||
[1.0, 1.6]
|
||||
]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_with_its_own_entry_beats_the_default() {
|
||||
let curves = Curves::parse(
|
||||
"version: 2
|
||||
default:
|
||||
points: [[0.0, 0.0], [0.25, 0.3], [0.5, 0.6], [0.75, 0.85], [1.0, 1.0]]
|
||||
bodies:
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
||||
assert_eq!(curves.body("Canon", "EOS 5D").ys[1], 0.30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_make_may_be_repeated_in_the_model() {
|
||||
// Canon writes "Canon" as the make and "Canon EOS 6D" as the model;
|
||||
// rawler's cleaned strings drop the repetition and both reach here.
|
||||
// One entry has to cover both or half the files on a card miss.
|
||||
let curves = Curves::parse(
|
||||
"version: 1
|
||||
bodies:
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.body("Canon", "Canon EOS 6D").ys[1], 0.35);
|
||||
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
||||
assert_eq!(curves.body("CANON", "eos 6d").ys[1], 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corporate_suffix_does_not_hide_a_body() {
|
||||
// The same Z 6 arrives as "Nikon"/"Z 6" from rawler's camera database
|
||||
// and as "NIKON CORPORATION"/"NIKON Z 6" from a DNG converted out of
|
||||
// the same file. Both must find the entry, or converting a file to
|
||||
// DNG would silently change how it renders.
|
||||
let curves = Curves::parse(
|
||||
"version: 1
|
||||
bodies:
|
||||
- make: Nikon
|
||||
model: Z 6
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.body("Nikon", "Z 6").ys[1], 0.35);
|
||||
assert_eq!(curves.body("NIKON CORPORATION", "NIKON Z 6").ys[1], 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn punctuation_and_spacing_do_not_decide_whether_a_body_is_known() {
|
||||
let curves = Curves::parse(
|
||||
"version: 1
|
||||
bodies:
|
||||
- make: Sony
|
||||
model: ILCE-7M3
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.body("SONY", "ILCE 7M3").ys[1], 0.35);
|
||||
assert_eq!(curves.body("sony", "ilce-7m3").ys[1], 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bad_entry_does_not_cost_the_rest() {
|
||||
// A user-contributed file with one typo should cost that body's
|
||||
// rendering, not every body's.
|
||||
let curves = Curves::parse(
|
||||
"version: 1
|
||||
bodies:
|
||||
- make: Broken
|
||||
model: Body
|
||||
points: [[0.0, 0.0], [0.25, 0.9], [0.5, 0.1], [0.75, 0.9], [1.0, 1.0]]
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.len(), 1);
|
||||
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
||||
assert!(curves.body("Broken", "Body").is_identity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_from_the_future_still_delivers_its_curves() {
|
||||
// The database is versioned independently of the binary, so a pack
|
||||
// published after this build may carry keys this build has never heard
|
||||
// of. It must still hand over the curves it does understand — failing
|
||||
// the parse would leave every body flat, which is the exact failure
|
||||
// FR-DEV-3e exists to prevent, delivered by the mechanism meant to
|
||||
// prevent it.
|
||||
let curves = Curves::parse(
|
||||
"version: 9
|
||||
look_table: ambitious
|
||||
bodies:
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
hue_twist: [1, 2, 3]
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("an unfamiliar key must not fail the parse");
|
||||
|
||||
assert_eq!(curves.version(), 9);
|
||||
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_body_with_no_default_gets_the_identity() {
|
||||
// Graceful fallback, stated as a property: never worse than a flat
|
||||
// render, and never a curve tuned for somebody else's sensor when the
|
||||
// database declines to offer one.
|
||||
let curves = Curves::parse("version: 1\nbodies: []\n").expect("parses");
|
||||
assert!(curves.body("Nobody", "Nothing").is_identity());
|
||||
}
|
||||
|
||||
/// A directory holding one `base_curves.yaml`, unique to the caller.
|
||||
fn a_pack_dir(name: &str, yaml: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("darkroom-base-curves-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("a writable temp directory");
|
||||
std::fs::write(dir.join("base_curves.yaml"), yaml).expect("write");
|
||||
dir
|
||||
}
|
||||
|
||||
const A_CANON_ENTRY: &str = "bodies:
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points: [[0.0, 0.0], [0.25, 0.42], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn a_newer_pack_on_disk_supersedes_the_built_in_database() {
|
||||
// **This is the requirement.** FR-DEV-3e asks for a profile database
|
||||
// versioned independently of the app binary "so bodies and curves can
|
||||
// be added without a release". A file with a higher version, dropped
|
||||
// in the profile directory, is what that means in practice.
|
||||
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
||||
let newer = format!("version: {}\n{A_CANON_ENTRY}", built_in.version() + 1);
|
||||
let dir = a_pack_dir("newer", &newer);
|
||||
|
||||
let chosen = choose(built_in.clone(), &[dir]);
|
||||
assert_eq!(chosen.version(), built_in.version() + 1);
|
||||
assert_eq!(chosen.body("Canon", "EOS 6D").ys[1], 0.42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_pack_does_not_survive_an_upgrade() {
|
||||
// The other direction, and the one that protects the user. Somebody
|
||||
// downloads a pack, a release later ships better curves for the same
|
||||
// bodies, and the forgotten file must not quietly hold the application
|
||||
// back at last year's rendering.
|
||||
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
||||
let stale = format!("version: {}\n{A_CANON_ENTRY}", built_in.version());
|
||||
let dir = a_pack_dir("stale", &stale);
|
||||
|
||||
let chosen = choose(built_in.clone(), &[dir]);
|
||||
assert_eq!(chosen.version(), built_in.version());
|
||||
assert_ne!(
|
||||
chosen.body("Canon", "EOS 6D").ys[1],
|
||||
0.42,
|
||||
"an equal version must not displace the built-in database"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_pack_costs_the_curves_and_not_the_photographs() {
|
||||
// A malformed profile file must degrade to the built-in database, not
|
||||
// to an error. The user came here to look at a photograph.
|
||||
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
||||
let dir = a_pack_dir("broken", "version: [this is not a number\n");
|
||||
|
||||
let chosen = choose(built_in.clone(), &[dir]);
|
||||
assert_eq!(chosen.version(), built_in.version());
|
||||
assert_eq!(chosen.len(), built_in.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_directory_with_no_pack_in_it_is_simply_skipped() {
|
||||
// The ordinary case on every machine: the search path exists, the file
|
||||
// does not. It must not be a warning, an error, or a slow path.
|
||||
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
||||
let missing = std::env::temp_dir().join("darkroom-base-curves-nothing-here");
|
||||
let _ = std::fs::remove_dir_all(&missing);
|
||||
|
||||
assert_eq!(choose(built_in.clone(), &[missing]), built_in);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_identity_is_recognised_as_doing_nothing() {
|
||||
assert!(BaseCurve::IDENTITY.is_identity());
|
||||
assert!(!Curves::parse(BUILT_IN)
|
||||
.expect("parses")
|
||||
.body("Canon", "EOS 6D")
|
||||
.is_identity());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/// TRACES: FR-RAW-4 | NFR-SEC-1
|
||||
/// Failures from decoding.
|
||||
///
|
||||
/// Per FR-RAW-4 a malformed file must not abort a batch, so these are always
|
||||
/// returned rather than panicking — and the decode path is the one place
|
||||
/// untrusted input arrives (NFR-SEC-1).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DecodeError {
|
||||
#[error("read failed: {0}")]
|
||||
Read(String),
|
||||
|
||||
#[error("unsupported or unrecognised format: {0}")]
|
||||
Unsupported(String),
|
||||
|
||||
#[error("decode failed: {0}")]
|
||||
Decode(String),
|
||||
|
||||
#[error("metadata unavailable: {0}")]
|
||||
Metadata(String),
|
||||
|
||||
#[error("no embedded preview in this file")]
|
||||
NoPreview,
|
||||
|
||||
#[error("embedded preview is corrupt: {0}")]
|
||||
CorruptPreview(String),
|
||||
}
|
||||
|
||||
impl DecodeError {
|
||||
/// Whether a fallback path might still produce an image.
|
||||
///
|
||||
/// A missing preview is not a failure to display the file — it means fall
|
||||
/// through to full decode (FR-CULL-2, M-11).
|
||||
pub fn has_fallback(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
DecodeError::NoPreview | DecodeError::CorruptPreview(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preview_failures_fall_through_rather_than_failing() {
|
||||
assert!(DecodeError::NoPreview.has_fallback());
|
||||
assert!(DecodeError::CorruptPreview("truncated".into()).has_fallback());
|
||||
// A genuinely unsupported file has nowhere to fall through to.
|
||||
assert!(!DecodeError::Unsupported("unknown".into()).has_fallback());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Embedded preview extraction — the fast display path.
|
||||
//!
|
||||
//! Every RAW container carries one or more JPEG previews, often at or near
|
||||
//! full resolution. Extracting one costs a fraction of a full decode, and is
|
||||
//! what makes culling feel instant (FR-CULL-1, NFR-P13: 50 ms per image).
|
||||
//!
|
||||
//! It is also what makes remote browsing viable: fetching ~1-3 MB of preview
|
||||
//! from an 80 MB file over WebDAV is the difference between usable and not on
|
||||
//! mobile data (FR-NC-3).
|
||||
|
||||
use crate::DecodeError;
|
||||
|
||||
/// How much of a file header to read when locating a preview.
|
||||
///
|
||||
/// Enough to cover the IFD structure of the TIFF-derived formats. Sized for
|
||||
/// remote range requests, where every byte costs.
|
||||
pub const PREVIEW_PROBE_BYTES: u64 = 256 * 1024;
|
||||
|
||||
/// A decoded preview image, RGBA8.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Preview {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Tightly packed RGBA, 4 bytes per pixel.
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Preview {
|
||||
/// TRACES: FR-DEV-3h
|
||||
/// Turn the pixels the right way up, in place.
|
||||
///
|
||||
/// Every path that shows a preview without the GPU needs this: the grid's
|
||||
/// thumbnails, and the read-only fallback develop shows when no decoder
|
||||
/// could open the file. An embedded preview is written in the sensor's
|
||||
/// orientation, not the photograph's, so a phone or a camera held sideways
|
||||
/// fills the grid with frames on their side until this runs.
|
||||
///
|
||||
/// Done before [`Self::downscale_to`] would be wasteful and after it is
|
||||
/// not: a quarter turn is a permutation, so it costs the same either way,
|
||||
/// and doing it on the smaller buffer moves a fraction of the bytes.
|
||||
///
|
||||
/// Allocates a second buffer rather than rotating in place. An in-place
|
||||
/// quarter turn on a non-square image is a cycle-following permutation
|
||||
/// that is both slower per pixel and far harder to get right, for a saving
|
||||
/// that a thumbnail-sized buffer does not need.
|
||||
pub fn apply_orientation(&mut self, orientation: dr_types::Orientation) {
|
||||
if orientation.is_normal() || self.width == 0 || self.height == 0 {
|
||||
return;
|
||||
}
|
||||
let (dw, dh) = orientation.oriented_size(self.width, self.height);
|
||||
|
||||
let mut out = vec![0u8; (dw as usize) * (dh as usize) * 4];
|
||||
for y in 0..dh {
|
||||
for x in 0..dw {
|
||||
let (sx, sy) = orientation.source_pixel(x, y, dw, dh);
|
||||
let s = ((sy * self.width + sx) * 4) as usize;
|
||||
let d = ((y * dw + x) * 4) as usize;
|
||||
out[d..d + 4].copy_from_slice(&self.rgba[s..s + 4]);
|
||||
}
|
||||
}
|
||||
|
||||
self.rgba = out;
|
||||
self.width = dw;
|
||||
self.height = dh;
|
||||
}
|
||||
|
||||
/// Downscale in place to fit within `max_dim` on the long edge.
|
||||
///
|
||||
/// A 5472x3648 preview is 79.8 MB of RGBA — far more than a grid cell or
|
||||
/// even a 4K viewport needs, and enough to exhaust a phone's budget after
|
||||
/// a handful of images (NFR-RES-1). Box-filtered rather than nearest, so
|
||||
/// downscaled thumbnails do not alias.
|
||||
pub fn downscale_to(&mut self, max_dim: u32) {
|
||||
let longest = self.width.max(self.height);
|
||||
if longest <= max_dim || longest == 0 {
|
||||
return;
|
||||
}
|
||||
let scale = max_dim as f32 / longest as f32;
|
||||
let (nw, nh) = (
|
||||
((self.width as f32 * scale).round() as u32).max(1),
|
||||
((self.height as f32 * scale).round() as u32).max(1),
|
||||
);
|
||||
|
||||
let mut out = vec![0u8; (nw as usize) * (nh as usize) * 4];
|
||||
let x_ratio = self.width as f32 / nw as f32;
|
||||
let y_ratio = self.height as f32 / nh as f32;
|
||||
|
||||
for y in 0..nh {
|
||||
let y0 = (y as f32 * y_ratio) as u32;
|
||||
let y1 = (((y + 1) as f32 * y_ratio) as u32)
|
||||
.min(self.height)
|
||||
.max(y0 + 1);
|
||||
for x in 0..nw {
|
||||
let x0 = (x as f32 * x_ratio) as u32;
|
||||
let x1 = (((x + 1) as f32 * x_ratio) as u32)
|
||||
.min(self.width)
|
||||
.max(x0 + 1);
|
||||
|
||||
let (mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32);
|
||||
for sy in y0..y1 {
|
||||
for sx in x0..x1 {
|
||||
let i = ((sy * self.width + sx) * 4) as usize;
|
||||
r += self.rgba[i] as u32;
|
||||
g += self.rgba[i + 1] as u32;
|
||||
b += self.rgba[i + 2] as u32;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let n = n.max(1);
|
||||
let o = ((y * nw + x) * 4) as usize;
|
||||
out[o] = (r / n) as u8;
|
||||
out[o + 1] = (g / n) as u8;
|
||||
out[o + 2] = (b / n) as u8;
|
||||
out[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
self.rgba = out;
|
||||
self.width = nw;
|
||||
self.height = nh;
|
||||
}
|
||||
|
||||
/// Whether this is large enough to be worth displaying at `target`.
|
||||
///
|
||||
/// Some bodies embed thumbnails only a few hundred pixels wide — Sony is
|
||||
/// the documented case. Displaying one where a larger render is wanted
|
||||
/// shows a soft image the user discovers only on zoom, so the caller
|
||||
/// should background-render instead (M-11).
|
||||
pub fn is_useful_at(&self, target: u32) -> bool {
|
||||
self.width.max(self.height) >= target
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-1 | NFR-P13
|
||||
/// Which embedded image to extract.
|
||||
///
|
||||
/// Containers carry several at different sizes, and decoding the
|
||||
/// full-resolution one to fill a grid cell is pure waste.
|
||||
///
|
||||
/// **Measured caveat (rawler 0.7.2):** the CR2 decoder implements only
|
||||
/// `full_image`; `thumbnail_image` and `preview_image` are unimplemented trait
|
||||
/// defaults returning `None`. So on Canon CR2 every rung currently resolves to
|
||||
/// the full-resolution JPEG at ~250 ms — 5× over NFR-P13's 50 ms budget.
|
||||
///
|
||||
/// Three ways out, in increasing cost: extract the smaller IFD ourselves
|
||||
/// (CR2 carries a 160×120 thumbnail and a ~1620×1080 preview in IFD1/IFD2),
|
||||
/// contribute the methods upstream, or cache a downscaled proxy on first
|
||||
/// sight. The ladder is written now so that fixing it is a decoder change
|
||||
/// rather than a change to every caller.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PreviewSize {
|
||||
/// Smallest available. Grid cells and rapid culling.
|
||||
Thumbnail,
|
||||
/// Mid-sized where the container has one. Single-image view.
|
||||
Screen,
|
||||
/// Largest available, usually full sensor resolution. Only where the
|
||||
/// display genuinely needs it.
|
||||
Full,
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-2 | FR-NC-3 | M-10
|
||||
/// Extract and decode an embedded preview at the requested size.
|
||||
///
|
||||
/// Takes bytes rather than a reader, because the caller usually has them
|
||||
/// already: a range read locally, or a `Range:` request remotely. Forcing a
|
||||
/// `Read + Seek` here would push remote callers into buffering the whole file.
|
||||
///
|
||||
/// Falls through the ladder — a container without the requested size yields
|
||||
/// the next available rather than failing (FR-CULL-2).
|
||||
///
|
||||
/// Returns [`DecodeError::NoPreview`] where there is none at all: a
|
||||
/// fall-through signal, not a failure (see [`DecodeError::has_fallback`]).
|
||||
pub fn extract_preview(bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError> {
|
||||
use rawler::rawsource::RawSource;
|
||||
|
||||
// A plain JPEG *is* its own preview — rawler has no decoder for one, and
|
||||
// a mixed folder must display sensibly (M-9).
|
||||
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
return decode_jpeg(bytes);
|
||||
}
|
||||
|
||||
let source = RawSource::new_from_slice(bytes);
|
||||
let decoder =
|
||||
rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?;
|
||||
let params = Default::default();
|
||||
|
||||
// Preference order per requested size, each falling through to the next.
|
||||
let attempts: &[PreviewSize] = match size {
|
||||
PreviewSize::Thumbnail => &[
|
||||
PreviewSize::Thumbnail,
|
||||
PreviewSize::Screen,
|
||||
PreviewSize::Full,
|
||||
],
|
||||
PreviewSize::Screen => &[
|
||||
PreviewSize::Screen,
|
||||
PreviewSize::Full,
|
||||
PreviewSize::Thumbnail,
|
||||
],
|
||||
PreviewSize::Full => &[PreviewSize::Full, PreviewSize::Screen],
|
||||
};
|
||||
|
||||
for attempt in attempts {
|
||||
let got = match attempt {
|
||||
PreviewSize::Thumbnail => decoder.thumbnail_image(&source, ¶ms),
|
||||
PreviewSize::Screen => decoder.preview_image(&source, ¶ms),
|
||||
PreviewSize::Full => decoder.full_image(&source, ¶ms),
|
||||
};
|
||||
if let Ok(Some(img)) = got {
|
||||
let rgb = img.to_rgb8();
|
||||
let (width, height) = (rgb.width(), rgb.height());
|
||||
if width > 0 && height > 0 {
|
||||
return Ok(Preview {
|
||||
width,
|
||||
height,
|
||||
rgba: rgb_to_rgba(rgb.as_raw(), width, height),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(DecodeError::NoPreview)
|
||||
}
|
||||
|
||||
/// Extract the largest available preview.
|
||||
///
|
||||
/// Convenience over [`extract_preview`]; prefer naming a size explicitly.
|
||||
pub fn extract_embedded_preview(bytes: &[u8]) -> Result<Preview, DecodeError> {
|
||||
extract_preview(bytes, PreviewSize::Full)
|
||||
}
|
||||
|
||||
/// Decode a standalone JPEG (an embedded preview already sliced out, or a
|
||||
/// JPEG file).
|
||||
pub fn decode_jpeg(bytes: &[u8]) -> Result<Preview, DecodeError> {
|
||||
let mut d = zune_jpeg::JpegDecoder::new(bytes);
|
||||
let pixels = d
|
||||
.decode()
|
||||
.map_err(|e| DecodeError::CorruptPreview(e.to_string()))?;
|
||||
let info = d
|
||||
.info()
|
||||
.ok_or_else(|| DecodeError::CorruptPreview("no image info".into()))?;
|
||||
|
||||
let (w, h) = (info.width as u32, info.height as u32);
|
||||
let expected = (w as usize) * (h as usize);
|
||||
|
||||
// zune yields RGB or grayscale depending on the source; normalise both to
|
||||
// RGBA so callers have one representation.
|
||||
let rgba = match pixels.len() / expected.max(1) {
|
||||
3 => rgb_to_rgba(&pixels, w, h),
|
||||
1 => pixels.iter().flat_map(|&g| [g, g, g, 255]).collect(),
|
||||
4 => pixels,
|
||||
n => {
|
||||
return Err(DecodeError::CorruptPreview(format!(
|
||||
"unexpected {n} channels"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Preview {
|
||||
width: w,
|
||||
height: h,
|
||||
rgba,
|
||||
})
|
||||
}
|
||||
|
||||
fn rgb_to_rgba(rgb: &[u8], w: u32, h: u32) -> Vec<u8> {
|
||||
let n = (w as usize) * (h as usize);
|
||||
let mut out = Vec::with_capacity(n * 4);
|
||||
for px in rgb.chunks_exact(3).take(n) {
|
||||
out.extend_from_slice(&[px[0], px[1], px[2], 255]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A preview whose every pixel encodes its own coordinates, so a
|
||||
/// misplaced one is identifiable rather than merely wrong.
|
||||
fn coded(width: u32, height: u32) -> Preview {
|
||||
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
rgba.extend_from_slice(&[x as u8, y as u8, 0, 255]);
|
||||
}
|
||||
}
|
||||
Preview {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_moves_every_pixel_where_the_orientation_says() {
|
||||
// Tag 6: the stored image's first row becomes the displayed right
|
||||
// edge, its first column the displayed top. A 4x2 landscape preview
|
||||
// therefore comes out 2x4 portrait, with stored (0,0) at the top right.
|
||||
let mut p = coded(4, 2);
|
||||
p.apply_orientation(dr_types::Orientation::from_exif(6));
|
||||
|
||||
assert_eq!((p.width, p.height), (2, 4));
|
||||
let at = |x: u32, y: u32| {
|
||||
let i = ((y * p.width + x) * 4) as usize;
|
||||
(p.rgba[i], p.rgba[i + 1])
|
||||
};
|
||||
// Displayed top-right reads stored (0, 0).
|
||||
assert_eq!(at(1, 0), (0, 0));
|
||||
// Displayed top-left reads stored (0, 1) — the last row of column 0.
|
||||
assert_eq!(at(0, 0), (0, 1));
|
||||
// Displayed bottom-right reads stored (3, 0).
|
||||
assert_eq!(at(1, 3), (3, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upright_file_is_left_untouched() {
|
||||
// The common case, and the one where an unnecessary reallocation
|
||||
// would be paid on every thumbnail in the library.
|
||||
let original = coded(4, 2);
|
||||
let mut p = original.clone();
|
||||
p.apply_orientation(dr_types::Orientation::NORMAL);
|
||||
assert_eq!(p, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_orientation_preserves_the_pixels_it_was_given() {
|
||||
// A turn or a mirror is a permutation: the same bytes, rearranged.
|
||||
// Anything else means a pixel was dropped, duplicated or read out of
|
||||
// bounds — and the bounds case would have panicked first.
|
||||
for tag in 1..=8u16 {
|
||||
let orientation = dr_types::Orientation::from_exif(tag);
|
||||
let mut p = coded(5, 3);
|
||||
p.apply_orientation(orientation);
|
||||
|
||||
assert_eq!(
|
||||
(p.width, p.height),
|
||||
orientation.oriented_size(5, 3),
|
||||
"tag {tag}"
|
||||
);
|
||||
let mut got: Vec<_> = p.rgba.chunks(4).map(|c| (c[0], c[1])).collect();
|
||||
got.sort_unstable();
|
||||
let mut want: Vec<_> = coded(5, 3).rgba.chunks(4).map(|c| (c[0], c[1])).collect();
|
||||
want.sort_unstable();
|
||||
assert_eq!(got, want, "tag {tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_preference_falls_through_in_order() {
|
||||
// A container missing the requested size must yield the next
|
||||
// available rather than failing (FR-CULL-2).
|
||||
// Ordering is asserted here; behaviour against real files is covered
|
||||
// by the smoke example.
|
||||
assert_ne!(PreviewSize::Thumbnail, PreviewSize::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usefulness_is_judged_on_the_long_edge() {
|
||||
let p = Preview {
|
||||
width: 1600,
|
||||
height: 1067,
|
||||
rgba: Vec::new(),
|
||||
};
|
||||
assert!(p.is_useful_at(1024));
|
||||
assert!(p.is_useful_at(1600));
|
||||
// A body embedding only a small thumbnail must trigger a background
|
||||
// render rather than showing a soft image.
|
||||
assert!(!p.is_useful_at(2048));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downscale_preserves_aspect_and_bounds_memory() {
|
||||
let mut p = Preview {
|
||||
width: 5472,
|
||||
height: 3648,
|
||||
rgba: vec![128; 5472 * 3648 * 4],
|
||||
};
|
||||
assert_eq!(p.rgba.len(), 79_847_424);
|
||||
|
||||
p.downscale_to(2048);
|
||||
assert_eq!(p.width, 2048);
|
||||
assert_eq!(p.height, 1365, "aspect preserved");
|
||||
assert_eq!(p.rgba.len(), (2048 * 1365 * 4) as usize);
|
||||
// A flat source must stay flat through the box filter.
|
||||
assert!(p
|
||||
.rgba
|
||||
.chunks_exact(4)
|
||||
.all(|px| px[0] == 128 && px[3] == 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downscale_is_a_noop_when_already_small() {
|
||||
let mut p = Preview {
|
||||
width: 720,
|
||||
height: 480,
|
||||
rgba: vec![7; 720 * 480 * 4],
|
||||
};
|
||||
let before = p.rgba.len();
|
||||
p.downscale_to(2048);
|
||||
assert_eq!((p.width, p.height, p.rgba.len()), (720, 480, before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_expands_to_rgba_opaque() {
|
||||
let rgb = [10, 20, 30, 40, 50, 60];
|
||||
let rgba = rgb_to_rgba(&rgb, 2, 1);
|
||||
assert_eq!(rgba, vec![10, 20, 30, 255, 40, 50, 60, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_jpeg_is_an_error_not_a_panic() {
|
||||
// Untrusted input arrives here (NFR-SEC-1); it must never panic.
|
||||
let err = decode_jpeg(&[0xFF, 0xD8, 0x00, 0x01, 0x02]).unwrap_err();
|
||||
assert!(matches!(err, DecodeError::CorruptPreview(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_is_an_error_not_a_panic() {
|
||||
assert!(decode_jpeg(&[]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "dr-export"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
# No platform dependency and no filesystem, deliberately. This crate turns a
|
||||
# rendered frame into *bytes* and a *name*; where those bytes go is the
|
||||
# caller's problem, because the answer differs by more than a path. On Linux
|
||||
# it is a file, on Android a SAF document descriptor with no path at all
|
||||
# (ARCH §6.9), and on either it may be a `PUT` to the server. A crate that
|
||||
# took a `Path` would work on exactly one of the three.
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
log.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
# Encoders. All three are pure Rust and already in the tree, which is the same
|
||||
# criterion that chose rustls, bundled SQLite and the Lensfun port: a C
|
||||
# dependency here would be one more thing to satisfy under the Android NDK.
|
||||
#
|
||||
# AVIF and JPEG XL (FR-EXP-1) are deliberately absent. The mature encoders for
|
||||
# both are C or C++ — libaom and libjxl — and ravif, the pure-Rust AVIF path,
|
||||
# is slow enough to change what a batch export feels like. Neither belongs in
|
||||
# the first version; see `format` in lib.rs for what happens when one is asked
|
||||
# for.
|
||||
jpeg-encoder.workspace = true
|
||||
png = "0.18"
|
||||
tiff = "0.11"
|
||||
|
||||
# The example runs the whole path — decode, GPU render, read back, encode,
|
||||
# write — so it needs what the library deliberately does not: a GPU, a
|
||||
# pipeline and a decoder. Dev-only, so none of it reaches a dependent.
|
||||
[dev-dependencies]
|
||||
dr-decode.workspace = true
|
||||
dr-gpu.workspace = true
|
||||
dr-pipeline.workspace = true
|
||||
env_logger.workspace = true
|
||||
pollster.workspace = true
|
||||
zune-jpeg.workspace = true
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Export a real file, end to end, from a real image.
|
||||
//!
|
||||
//! cargo run -p dr-export --example export -- <file.jpg|file.cr2> [out-dir]
|
||||
//!
|
||||
//! Deliberately the *whole* path and not a unit test of the encoder: decode,
|
||||
//! demosaic or upload, run the develop chain on the GPU at full resolution,
|
||||
//! read the result back through `AdjustPass::export_pixels`, resize, sharpen,
|
||||
//! encode, and write. A test can prove the JPEG has the right magic bytes; it
|
||||
//! cannot tell anyone whether the picture came out looking like the picture.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dr_export::{export, Frame, NameContext, SourceMetadata};
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
|
||||
use dr_pipeline::EditGraph;
|
||||
use dr_types::{ColourSpace, ExportFormat, ExportSettings, OutputSharpening, SizingMode};
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info,wgpu=warn"))
|
||||
.init();
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
let Some(input) = args.next() else {
|
||||
eprintln!("usage: export <file.jpg|file.cr2> [out-dir]");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let out_dir = PathBuf::from(args.next().unwrap_or_else(|| ".".into()));
|
||||
let input = PathBuf::from(input);
|
||||
|
||||
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
|
||||
println!("gpu: {} ({:?})", ctx.adapter_name(), ctx.backend());
|
||||
|
||||
// Decode. A RAW goes through the demosaicer; a JPEG is already RGB and
|
||||
// takes the same path every operation after the sensor stage does.
|
||||
let bytes = std::fs::read(&input).expect("read input");
|
||||
// From content, not from the extension — dr-decode is emphatic that an
|
||||
// extension is only a hint. Its own `probe` reports a crate-private
|
||||
// `Format`, so the SOI marker is checked directly here rather than
|
||||
// widening that API for an example.
|
||||
let is_jpeg = bytes.starts_with(&[0xFF, 0xD8, 0xFF]);
|
||||
let source = if !is_jpeg {
|
||||
let raw = dr_decode::decode(&bytes).expect("decode raw");
|
||||
let demosaicer = Demosaicer::new(&ctx).expect("demosaicer");
|
||||
demosaicer.run(&raw).expect("demosaic")
|
||||
} else {
|
||||
let (rgba, w, h) = decode_jpeg(&bytes);
|
||||
DemosaicedImage::from_rgba8(&ctx, &rgba, w, h).expect("upload")
|
||||
};
|
||||
|
||||
// An edit worth seeing in the output, so a broken pipeline is obvious
|
||||
// rather than subtle.
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_param(
|
||||
dr_pipeline::ops::exposure::ID,
|
||||
dr_pipeline::ops::exposure::EXPOSURE,
|
||||
0.35,
|
||||
);
|
||||
graph.set_param(
|
||||
dr_pipeline::ops::contrast::ID,
|
||||
dr_pipeline::ops::contrast::CONTRAST,
|
||||
18.0,
|
||||
);
|
||||
graph.set_param(
|
||||
dr_pipeline::ops::saturation::ID,
|
||||
dr_pipeline::ops::saturation::SATURATION,
|
||||
12.0,
|
||||
);
|
||||
|
||||
// Full resolution, not the viewport (FR-EXP-9). This is the one thing an
|
||||
// export must not economise on.
|
||||
let (sw, sh) = source.size();
|
||||
let (fw, fh) = graph.output_size(sw, sh);
|
||||
println!("source {sw}×{sh}, framed {fw}×{fh}");
|
||||
|
||||
// The output space is chosen *here*, before the render, because that is
|
||||
// where it takes effect: the primaries conversion and the encode are the
|
||||
// last two lines of the generated shader (FR-EXP-2). Asking for it at the
|
||||
// encoder would be too late — the pixels would already be clipped.
|
||||
let space = ColourSpace::DisplayP3;
|
||||
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
let shader = graph.compose_for(space);
|
||||
let t = std::time::Instant::now();
|
||||
adjust.render(&source, &shader, fw, fh).expect("render");
|
||||
let (pixels, w, h) = adjust.export_pixels().expect("read back");
|
||||
println!(
|
||||
"rendered {w}×{h} in {:.0} ms as {}",
|
||||
t.elapsed().as_secs_f32() * 1000.0,
|
||||
space.label()
|
||||
);
|
||||
|
||||
let frame = Frame::in_space(w, h, pixels, space).expect("well-formed frame");
|
||||
|
||||
let stem = input
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "export".into());
|
||||
|
||||
// TRACES: FR-EXP-8
|
||||
// What the input said about itself, transcribed field by field into the
|
||||
// allowlist `dr-export` will write from. The example passes it because
|
||||
// this is the one place in the tree that produces files a person can open
|
||||
// in exiftool — a unit test can prove a GPS directory is absent from a
|
||||
// byte slice, but only a real export proves that a real photograph comes
|
||||
// out of the far end still knowing which camera took it.
|
||||
//
|
||||
// The defaults apply, so the files written here carry the camera, the
|
||||
// lens, the exposure and the rights statement, and carry no coordinates.
|
||||
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
|
||||
let source_metadata = SourceMetadata {
|
||||
make: meta.make.clone(),
|
||||
model: meta.model.clone(),
|
||||
lens: meta.lens.clone(),
|
||||
shutter: meta.shutter,
|
||||
aperture: meta.aperture,
|
||||
iso: meta.iso,
|
||||
focal_length: meta.focal_length,
|
||||
captured_at: meta.captured_at,
|
||||
captured_offset: meta.captured_offset,
|
||||
artist: meta.artist.clone(),
|
||||
copyright: meta.copyright.clone(),
|
||||
location: meta.location,
|
||||
};
|
||||
|
||||
// One of each format, so the run exercises every encoder that exists.
|
||||
for (format, sizing, sharpening) in [
|
||||
(
|
||||
ExportFormat::Jpeg,
|
||||
SizingMode::Original,
|
||||
OutputSharpening::None,
|
||||
),
|
||||
(
|
||||
ExportFormat::Jpeg,
|
||||
SizingMode::LongEdge(1200),
|
||||
OutputSharpening::Screen,
|
||||
),
|
||||
(
|
||||
ExportFormat::Png,
|
||||
SizingMode::LongEdge(600),
|
||||
OutputSharpening::Screen,
|
||||
),
|
||||
(
|
||||
ExportFormat::Tiff8,
|
||||
SizingMode::Percentage(25),
|
||||
OutputSharpening::MattePaper,
|
||||
),
|
||||
(
|
||||
ExportFormat::Tiff16,
|
||||
SizingMode::Percentage(25),
|
||||
OutputSharpening::MattePaper,
|
||||
),
|
||||
] {
|
||||
let settings = ExportSettings {
|
||||
format,
|
||||
sizing,
|
||||
sharpening,
|
||||
colour_space: space,
|
||||
filename_template: "{name}-{dimensions}".into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// The size has to be known before the name, because `{dimensions}` is
|
||||
// part of it — which is why sizing is resolved here and not inside
|
||||
// `export`.
|
||||
let (tw, th) = dr_export::target_size(w, h, sizing, settings.allow_upscaling);
|
||||
let ctx = NameContext {
|
||||
source_stem: &stem,
|
||||
sequence: 1,
|
||||
date: "",
|
||||
width: tw,
|
||||
height: th,
|
||||
preset: "",
|
||||
};
|
||||
let name = dr_export::resolve_name(
|
||||
&settings.filename_template,
|
||||
&ctx,
|
||||
format,
|
||||
settings.collision,
|
||||
&|n| out_dir.join(n).exists(),
|
||||
)
|
||||
.expect("a free name");
|
||||
|
||||
let t = std::time::Instant::now();
|
||||
let out = export(&frame, &settings, name, Some(&source_metadata)).expect("export");
|
||||
let path = out_dir.join(&out.name);
|
||||
std::fs::write(&path, &out.bytes).expect("write");
|
||||
println!(
|
||||
"{:>10} {:>5}×{:<5} {:>8} KB {:>5.0} ms {}",
|
||||
format.label(),
|
||||
out.width,
|
||||
out.height,
|
||||
out.bytes.len() / 1024,
|
||||
t.elapsed().as_secs_f32() * 1000.0,
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_jpeg(bytes: &[u8]) -> (Vec<u8>, u32, u32) {
|
||||
let mut decoder = zune_jpeg::JpegDecoder::new(bytes);
|
||||
let pixels = decoder.decode().expect("decode jpeg");
|
||||
let info = decoder.info().expect("jpeg info");
|
||||
let (w, h) = (u32::from(info.width), u32::from(info.height));
|
||||
|
||||
// zune gives RGB; the GPU upload wants RGBA.
|
||||
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
|
||||
for px in pixels.chunks_exact(3) {
|
||||
rgba.extend_from_slice(&[px[0], px[1], px[2], 255]);
|
||||
}
|
||||
(rgba, w, h)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! TRACES: NFR-ARCH-4
|
||||
//! Typed export failures.
|
||||
//!
|
||||
//! Every variant is something a caller can act on or report. A batch export
|
||||
//! runs unattended over hundreds of frames (FR-EXP-7), so "what went wrong
|
||||
//! with which file" has to survive as data rather than as a log line.
|
||||
|
||||
use dr_types::{ColourSpace, ExportFormat};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ExportError {
|
||||
#[error("frame buffer is {got} bytes, expected {expected}")]
|
||||
FrameSize { expected: usize, got: usize },
|
||||
|
||||
#[error("frame has no pixels")]
|
||||
EmptyFrame,
|
||||
|
||||
/// Asked for a format with no encoder in this build.
|
||||
///
|
||||
/// Not a panic and not a silent substitution: the settings page offers
|
||||
/// AVIF and JPEG XL because FR-EXP-1 lists them, and a build without them
|
||||
/// should say so rather than quietly writing a JPEG under a `.avif` name.
|
||||
#[error("{} export is not supported yet", .0.label())]
|
||||
FormatUnsupported(ExportFormat),
|
||||
|
||||
/// TRACES: FR-EXP-2
|
||||
/// The frame was rendered into one colour space and asked to be labelled
|
||||
/// another.
|
||||
///
|
||||
/// Not a limitation of the encoders — all four spaces embed a correct
|
||||
/// profile. It is that the conversion happens in the shader, before the
|
||||
/// clip to 0..1, so a frame is in exactly one space by the time it gets
|
||||
/// here. The caller composes with `EditGraph::compose_for` to change which.
|
||||
#[error(
|
||||
"the frame was rendered in {} but a {} file was asked for",
|
||||
.rendered.label(),
|
||||
.requested.label()
|
||||
)]
|
||||
ColourSpaceMismatch {
|
||||
rendered: ColourSpace,
|
||||
requested: ColourSpace,
|
||||
},
|
||||
|
||||
#[error("encoding failed: {0}")]
|
||||
Encode(String),
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
//! TRACES: FR-EXP-8
|
||||
//! Building an EXIF block, rather than copying one.
|
||||
//!
|
||||
//! # Why this is written by hand and not with a crate
|
||||
//!
|
||||
//! Two reasons, in order of importance.
|
||||
//!
|
||||
//! The first is the privacy behaviour. Every EXIF library worth using offers a
|
||||
//! "load the source block, delete these tags, write it back" shape, and that
|
||||
//! shape is the wrong one here: it makes the file that leaves the machine a
|
||||
//! copy of the source's metadata *minus what we thought to remove*, so every
|
||||
//! tag nobody has thought about — a vendor's proprietary sub-directory, a
|
||||
//! serial number under a tag id this build has never seen — travels by
|
||||
//! default. Constructing the block from a fixed list of parsed values inverts
|
||||
//! that. What is written is exactly what appears in [`crate::SourceMetadata`],
|
||||
//! and a tag that is not in this file cannot end up in the output no matter
|
||||
//! what the source contained. The allowlist *is* the implementation.
|
||||
//!
|
||||
//! The second is the dependency policy. The root `Cargo.toml` explains why
|
||||
//! nothing here may link C — this tree has to build under the Android NDK —
|
||||
//! and the mature EXIF writers are bindings. This is a couple of hundred
|
||||
//! lines of offset arithmetic against a specification that has not changed
|
||||
//! since 2010, and it is the same TIFF structure `dr-decode` already reads.
|
||||
//!
|
||||
//! # What the block is
|
||||
//!
|
||||
//! A complete little-endian TIFF: an 8-byte header, IFD0 with the identity
|
||||
//! and rights tags, an Exif sub-IFD with the capture tags, optionally a GPS
|
||||
//! sub-IFD, and a heap of values too long to sit inside an entry. JPEG carries
|
||||
//! it in an APP1 segment behind the marker `Exif\0\0`; PNG carries the same
|
||||
//! bytes in an `eXIf` chunk with no marker. TIFF does not use this at all —
|
||||
//! its own directory *is* the EXIF, so `encode.rs` writes the tags there
|
||||
//! directly.
|
||||
|
||||
use crate::metadata::SourceMetadata;
|
||||
|
||||
/// One entry's value, in the handful of TIFF types this writer emits.
|
||||
enum Value {
|
||||
/// NUL-terminated, as the specification requires; the terminator is
|
||||
/// counted, which is the detail readers trip over when it is missing.
|
||||
Ascii(String),
|
||||
Byte(Vec<u8>),
|
||||
Short(u16),
|
||||
Long(u32),
|
||||
/// Type 7. Used only for `ExifVersion`, which is four characters that are
|
||||
/// deliberately *not* a string.
|
||||
Undefined(&'static [u8]),
|
||||
/// Numerator and denominator pairs. A coordinate is three of them.
|
||||
Rational(Vec<(u32, u32)>),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
fn field_type(&self) -> u16 {
|
||||
match self {
|
||||
Value::Byte(_) => 1,
|
||||
Value::Ascii(_) => 2,
|
||||
Value::Short(_) => 3,
|
||||
Value::Long(_) => 4,
|
||||
Value::Rational(_) => 5,
|
||||
Value::Undefined(_) => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// The element count, which is not the byte length: a rational counts as
|
||||
/// one element per eight bytes.
|
||||
fn count(&self) -> u32 {
|
||||
match self {
|
||||
Value::Ascii(s) => s.len() as u32 + 1,
|
||||
Value::Byte(b) => b.len() as u32,
|
||||
Value::Undefined(b) => b.len() as u32,
|
||||
Value::Short(_) | Value::Long(_) => 1,
|
||||
Value::Rational(r) => r.len() as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// The payload, in file order.
|
||||
fn payload(&self) -> Vec<u8> {
|
||||
match self {
|
||||
Value::Ascii(s) => {
|
||||
let mut out = s.as_bytes().to_vec();
|
||||
out.push(0);
|
||||
out
|
||||
}
|
||||
Value::Byte(b) => b.clone(),
|
||||
Value::Undefined(b) => b.to_vec(),
|
||||
Value::Short(v) => v.to_le_bytes().to_vec(),
|
||||
Value::Long(v) => v.to_le_bytes().to_vec(),
|
||||
Value::Rational(r) => r
|
||||
.iter()
|
||||
.flat_map(|(n, d)| {
|
||||
let mut b = n.to_le_bytes().to_vec();
|
||||
b.extend_from_slice(&d.to_le_bytes());
|
||||
b
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An IFD under construction.
|
||||
type Entries = Vec<(u16, Value)>;
|
||||
|
||||
/// Tag numbers. Named rather than inlined because a mistyped one produces a
|
||||
/// file that still parses and says something else entirely.
|
||||
pub(crate) mod tag {
|
||||
pub(crate) const MAKE: u16 = 0x010F;
|
||||
pub(crate) const MODEL: u16 = 0x0110;
|
||||
pub(crate) const SOFTWARE: u16 = 0x0131;
|
||||
pub(crate) const DATE_TIME: u16 = 0x0132;
|
||||
pub(crate) const ARTIST: u16 = 0x013B;
|
||||
pub(crate) const COPYRIGHT: u16 = 0x8298;
|
||||
pub(crate) const EXIF_IFD: u16 = 0x8769;
|
||||
pub(crate) const GPS_IFD: u16 = 0x8825;
|
||||
|
||||
pub(crate) const EXPOSURE_TIME: u16 = 0x829A;
|
||||
pub(crate) const FNUMBER: u16 = 0x829D;
|
||||
pub(crate) const ISO: u16 = 0x8827;
|
||||
pub(crate) const EXIF_VERSION: u16 = 0x9000;
|
||||
pub(crate) const DATE_TIME_ORIGINAL: u16 = 0x9003;
|
||||
pub(crate) const OFFSET_TIME_ORIGINAL: u16 = 0x9011;
|
||||
pub(crate) const FOCAL_LENGTH: u16 = 0x920A;
|
||||
pub(crate) const PIXEL_X: u16 = 0xA002;
|
||||
pub(crate) const PIXEL_Y: u16 = 0xA003;
|
||||
pub(crate) const LENS_MODEL: u16 = 0xA434;
|
||||
|
||||
pub(crate) const GPS_VERSION_ID: u16 = 0x0000;
|
||||
pub(crate) const GPS_LATITUDE_REF: u16 = 0x0001;
|
||||
pub(crate) const GPS_LATITUDE: u16 = 0x0002;
|
||||
pub(crate) const GPS_LONGITUDE_REF: u16 = 0x0003;
|
||||
pub(crate) const GPS_LONGITUDE: u16 = 0x0004;
|
||||
pub(crate) const GPS_ALTITUDE_REF: u16 = 0x0005;
|
||||
pub(crate) const GPS_ALTITUDE: u16 = 0x0006;
|
||||
}
|
||||
|
||||
/// What DarkRoom calls itself in a file it wrote.
|
||||
///
|
||||
/// Not vanity: an export is a derived file, and a reader that knows which
|
||||
/// program produced it can tell a camera original from a rendition without
|
||||
/// guessing from the absence of a maker note.
|
||||
pub(crate) const SOFTWARE: &str = "DarkRoom";
|
||||
|
||||
/// The complete EXIF block for JPEG's APP1 and PNG's `eXIf`.
|
||||
///
|
||||
/// `width`/`height` are the *exported* dimensions, not the source's: the
|
||||
/// pixel-dimension tags describe the file they are in, and a reader that
|
||||
/// trusts them after a resize would report the wrong size for the image it is
|
||||
/// holding.
|
||||
///
|
||||
/// `None` where there is nothing to say. An empty EXIF block is not the same
|
||||
/// as no EXIF block — it is a structure a reader must parse to discover it
|
||||
/// learned nothing — and the second is the better file.
|
||||
pub(crate) fn block(md: &SourceMetadata, width: u32, height: u32) -> Option<Vec<u8>> {
|
||||
let ifd0 = main_entries(md);
|
||||
let exif = exif_entries(md, width, height);
|
||||
let gps = gps_entries(md);
|
||||
if ifd0.is_empty() && exif.is_empty() && gps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(assemble(ifd0, exif, gps))
|
||||
}
|
||||
|
||||
/// Lay the three directories and their heap out in the block.
|
||||
///
|
||||
/// The order is fixed — IFD0, Exif, GPS, heap — because the pointers have to
|
||||
/// be known before IFD0 is serialised, and an IFD's size is decided by its
|
||||
/// entry count alone: two bytes of count, twelve per entry, four for the link
|
||||
/// to the next directory.
|
||||
fn assemble(mut ifd0: Entries, exif: Entries, gps: Entries) -> Vec<u8> {
|
||||
const HEADER: u32 = 8;
|
||||
let size = |n: usize| 2 + 12 * n as u32 + 4;
|
||||
|
||||
// The pointer entries are part of IFD0's count, so they have to be added
|
||||
// before its size is taken — a chicken-and-egg the specification resolves
|
||||
// by making entry size fixed.
|
||||
let pointers = usize::from(!exif.is_empty()) + usize::from(!gps.is_empty());
|
||||
let ifd0_size = size(ifd0.len() + pointers);
|
||||
|
||||
let exif_offset = HEADER + ifd0_size;
|
||||
let gps_offset = exif_offset + if exif.is_empty() { 0 } else { size(exif.len()) };
|
||||
let heap_base = gps_offset + if gps.is_empty() { 0 } else { size(gps.len()) };
|
||||
|
||||
if !exif.is_empty() {
|
||||
ifd0.push((tag::EXIF_IFD, Value::Long(exif_offset)));
|
||||
}
|
||||
if !gps.is_empty() {
|
||||
ifd0.push((tag::GPS_IFD, Value::Long(gps_offset)));
|
||||
}
|
||||
|
||||
let mut heap = Vec::new();
|
||||
let ifd0_bytes = directory(ifd0, heap_base, &mut heap);
|
||||
let exif_bytes = directory(exif, heap_base, &mut heap);
|
||||
let gps_bytes = directory(gps, heap_base, &mut heap);
|
||||
|
||||
let mut out = Vec::with_capacity(HEADER as usize + heap.len() + 128);
|
||||
// Little-endian, magic 42, first directory at byte 8. Little-endian
|
||||
// because every value written below is, and a header that disagreed with
|
||||
// its own body is the one corruption a reader cannot recover from.
|
||||
out.extend_from_slice(b"II");
|
||||
out.extend_from_slice(&42u16.to_le_bytes());
|
||||
out.extend_from_slice(&HEADER.to_le_bytes());
|
||||
out.extend_from_slice(&ifd0_bytes);
|
||||
out.extend_from_slice(&exif_bytes);
|
||||
out.extend_from_slice(&gps_bytes);
|
||||
out.extend_from_slice(&heap);
|
||||
out
|
||||
}
|
||||
|
||||
/// Serialise one directory, spilling long values onto the shared heap.
|
||||
///
|
||||
/// Entries are sorted by tag: TIFF requires ascending order within a
|
||||
/// directory, and while most readers cope with any order, the ones that
|
||||
/// binary-search stop at the first tag they cannot place.
|
||||
fn directory(mut entries: Entries, heap_base: u32, heap: &mut Vec<u8>) -> Vec<u8> {
|
||||
if entries.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
entries.sort_by_key(|(tag, _)| *tag);
|
||||
|
||||
let mut out = Vec::with_capacity(2 + entries.len() * 12 + 4);
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
for (tag, value) in &entries {
|
||||
out.extend_from_slice(&tag.to_le_bytes());
|
||||
out.extend_from_slice(&value.field_type().to_le_bytes());
|
||||
out.extend_from_slice(&value.count().to_le_bytes());
|
||||
|
||||
let payload = value.payload();
|
||||
if payload.len() <= 4 {
|
||||
// Four bytes or fewer live in the entry itself, left-justified and
|
||||
// zero-padded.
|
||||
let mut inline = payload.clone();
|
||||
inline.resize(4, 0);
|
||||
out.extend_from_slice(&inline);
|
||||
} else {
|
||||
out.extend_from_slice(&(heap_base + heap.len() as u32).to_le_bytes());
|
||||
heap.extend_from_slice(&payload);
|
||||
// Values start on even offsets. Not every reader cares; the ones
|
||||
// that do read a short from an odd address and get nonsense.
|
||||
if heap.len() % 2 == 1 {
|
||||
heap.push(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No directory follows this one. The Exif and GPS sub-directories are
|
||||
// pointed at, not chained, so this is zero in all three.
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// IFD0: who took it, with what, and who owns it.
|
||||
///
|
||||
/// **No orientation tag, deliberately.** The frame reaching the encoder has
|
||||
/// already had the source's orientation applied by the pipeline — it is
|
||||
/// upright pixels — so copying the source's tag across would tell every
|
||||
/// reader to rotate an image that is already the right way up. A portrait
|
||||
/// frame would come out on its side in exactly the viewers that honour the
|
||||
/// tag, which is most of them.
|
||||
fn main_entries(md: &SourceMetadata) -> Entries {
|
||||
let mut e = Entries::new();
|
||||
push_ascii(&mut e, tag::MAKE, md.make.as_deref());
|
||||
push_ascii(&mut e, tag::MODEL, md.model.as_deref());
|
||||
push_ascii(&mut e, tag::ARTIST, md.artist.as_deref());
|
||||
push_ascii(&mut e, tag::COPYRIGHT, md.copyright.as_deref());
|
||||
e.push((tag::SOFTWARE, Value::Ascii(SOFTWARE.to_string())));
|
||||
// IFD0's `DateTime` is nominally when the file was written, and this is
|
||||
// the capture time instead. That is what the rest of the world does —
|
||||
// and it is what `dr-decode` falls back to for scanner output that has no
|
||||
// `DateTimeOriginal` — so a re-import of an export lands on the timeline
|
||||
// where the original did rather than on the day it was exported.
|
||||
if let Some(t) = md.captured_at.map(datetime) {
|
||||
e.push((tag::DATE_TIME, Value::Ascii(t)));
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// The Exif sub-IFD: the exposure, and what made it.
|
||||
fn exif_entries(md: &SourceMetadata, width: u32, height: u32) -> Entries {
|
||||
let mut e = Entries::new();
|
||||
// "0232" is Exif 2.32. A sub-directory without a version is technically
|
||||
// malformed, and some readers refuse the whole block over it.
|
||||
e.push((tag::EXIF_VERSION, Value::Undefined(b"0232")));
|
||||
e.push((tag::PIXEL_X, Value::Long(width)));
|
||||
e.push((tag::PIXEL_Y, Value::Long(height)));
|
||||
push_ascii(&mut e, tag::LENS_MODEL, md.lens.as_deref());
|
||||
if let Some(t) = md.captured_at.map(datetime) {
|
||||
e.push((tag::DATE_TIME_ORIGINAL, Value::Ascii(t)));
|
||||
}
|
||||
if let Some(o) = md.captured_offset.map(offset) {
|
||||
e.push((tag::OFFSET_TIME_ORIGINAL, Value::Ascii(o)));
|
||||
}
|
||||
if let Some(s) = md.shutter.filter(|s| *s > 0.0) {
|
||||
e.push((tag::EXPOSURE_TIME, Value::Rational(vec![shutter(s)])));
|
||||
}
|
||||
if let Some(f) = md.aperture.filter(|f| *f > 0.0) {
|
||||
e.push((tag::FNUMBER, Value::Rational(vec![tenths(f)])));
|
||||
}
|
||||
if let Some(f) = md.focal_length.filter(|f| *f > 0.0) {
|
||||
e.push((tag::FOCAL_LENGTH, Value::Rational(vec![tenths(f)])));
|
||||
}
|
||||
// The tag is a SHORT, so a sensitivity above 65535 has no representation
|
||||
// in it. Dropped rather than truncated: ISO 102400 written as 36864 is a
|
||||
// lie, and an absent tag is not.
|
||||
if let Some(iso) = md.iso.filter(|v| *v <= u32::from(u16::MAX)) {
|
||||
e.push((tag::ISO, Value::Short(iso as u16)));
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// The GPS sub-IFD.
|
||||
///
|
||||
/// Empty unless the caller has already decided that coordinates may be
|
||||
/// written — see [`SourceMetadata::sanitised`], which is where the stripping
|
||||
/// happens. Nothing in this file consults the settings, so there is exactly
|
||||
/// one place to look to answer "can this export carry a location".
|
||||
fn gps_entries(md: &SourceMetadata) -> Entries {
|
||||
let Some(loc) = md.location else {
|
||||
return Entries::new();
|
||||
};
|
||||
let mut e = Entries::new();
|
||||
// 2.3.0.0, the current GPS tag version.
|
||||
e.push((tag::GPS_VERSION_ID, Value::Byte(vec![2, 3, 0, 0])));
|
||||
e.push((
|
||||
tag::GPS_LATITUDE_REF,
|
||||
Value::Ascii(if loc.latitude < 0.0 { "S" } else { "N" }.into()),
|
||||
));
|
||||
e.push((tag::GPS_LATITUDE, Value::Rational(dms(loc.latitude))));
|
||||
e.push((
|
||||
tag::GPS_LONGITUDE_REF,
|
||||
Value::Ascii(if loc.longitude < 0.0 { "W" } else { "E" }.into()),
|
||||
));
|
||||
e.push((tag::GPS_LONGITUDE, Value::Rational(dms(loc.longitude))));
|
||||
if let Some(alt) = loc.altitude {
|
||||
// The altitude itself is unsigned; below sea level is a separate byte.
|
||||
e.push((
|
||||
tag::GPS_ALTITUDE_REF,
|
||||
Value::Byte(vec![u8::from(alt < 0.0)]),
|
||||
));
|
||||
e.push((
|
||||
tag::GPS_ALTITUDE,
|
||||
Value::Rational(vec![((alt.abs() * 100.0).round() as u32, 100)]),
|
||||
));
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
fn push_ascii(entries: &mut Entries, tag: u16, value: Option<&str>) {
|
||||
// An empty string is a tag saying nothing, which is worse than no tag: it
|
||||
// overwrites whatever a reader would otherwise have inferred.
|
||||
if let Some(v) = value.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
entries.push((tag, Value::Ascii(v.to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed degrees back into the tag's degrees/minutes/seconds.
|
||||
///
|
||||
/// The sign is carried by the hemisphere letter, so this takes the magnitude.
|
||||
/// Seconds keep four decimal places, which is about 3 mm — far finer than any
|
||||
/// consumer fix, and enough that a round trip through the tag does not move
|
||||
/// the pin.
|
||||
pub(crate) fn dms(degrees: f64) -> Vec<(u32, u32)> {
|
||||
let d = degrees.abs();
|
||||
let whole = d.trunc();
|
||||
let minutes = (d - whole) * 60.0;
|
||||
let seconds = (minutes - minutes.trunc()) * 60.0;
|
||||
vec![
|
||||
(whole as u32, 1),
|
||||
(minutes.trunc() as u32, 1),
|
||||
((seconds * 10_000.0).round() as u32, 10_000),
|
||||
]
|
||||
}
|
||||
|
||||
/// A shutter speed as the fraction a photographer would recognise.
|
||||
///
|
||||
/// `1/250`, not `4/1000`. Both are the same number and every reader computes
|
||||
/// the same exposure from either, but the first is what the camera wrote and
|
||||
/// what a properties panel displays verbatim.
|
||||
pub(crate) fn shutter(seconds: f32) -> (u32, u32) {
|
||||
if seconds < 1.0 {
|
||||
(1, (1.0 / seconds).round().max(1.0) as u32)
|
||||
} else {
|
||||
((seconds * 10.0).round() as u32, 10)
|
||||
}
|
||||
}
|
||||
|
||||
/// f/2.8 and 85 mm as tenths, which is how cameras write both.
|
||||
pub(crate) fn tenths(value: f32) -> (u32, u32) {
|
||||
((value * 10.0).round().max(0.0) as u32, 10)
|
||||
}
|
||||
|
||||
/// Unix seconds as EXIF's `"YYYY:MM:DD HH:MM:SS"`.
|
||||
///
|
||||
/// The reading is a wall clock with no zone — that is what the tag means, and
|
||||
/// what `dr-decode` parsed it as — so this is the exact inverse of that parse
|
||||
/// and involves no timezone conversion. The zone, where the source recorded
|
||||
/// one, travels separately in `OffsetTimeOriginal`.
|
||||
pub(crate) fn datetime(unix: i64) -> String {
|
||||
let days = unix.div_euclid(86_400);
|
||||
let secs = unix.rem_euclid(86_400);
|
||||
|
||||
// Howard Hinnant's civil-from-days, the inverse of the days-from-civil
|
||||
// that `dr-decode` uses to parse. Eras of 400 years, shifted so that the
|
||||
// arithmetic never sees a negative.
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
|
||||
format!(
|
||||
"{y:04}:{m:02}:{d:02} {:02}:{:02}:{:02}",
|
||||
secs / 3600,
|
||||
(secs / 60) % 60,
|
||||
secs % 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Minutes east of UTC as EXIF's `"+HH:MM"`.
|
||||
pub(crate) fn offset(minutes: i32) -> String {
|
||||
let sign = if minutes < 0 { '-' } else { '+' };
|
||||
let m = minutes.unsigned_abs();
|
||||
format!("{sign}{:02}:{:02}", m / 60, m % 60)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_types::Location;
|
||||
|
||||
#[test]
|
||||
fn a_capture_time_survives_the_round_trip_through_the_tag() {
|
||||
// The parse side lives in `dr-decode` and is exercised against real
|
||||
// files; this is the inverse, and the two meeting in the middle is
|
||||
// what keeps an exported frame on the same point of the timeline as
|
||||
// the original.
|
||||
assert_eq!(datetime(1_372_462_374), "2013:06:28 23:32:54");
|
||||
assert_eq!(datetime(0), "1970:01:01 00:00:00");
|
||||
// A leap day, which is where a hand-rolled calendar goes wrong.
|
||||
assert_eq!(datetime(1_709_164_800), "2024:02:29 00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zone_is_written_the_way_the_tag_spells_it() {
|
||||
assert_eq!(offset(120), "+02:00");
|
||||
assert_eq!(offset(-330), "-05:30");
|
||||
assert_eq!(offset(0), "+00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shutter_speed_keeps_the_photographers_fraction() {
|
||||
assert_eq!(shutter(1.0 / 250.0), (1, 250));
|
||||
assert_eq!(shutter(2.5), (25, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degrees_round_trip_through_the_tags_triple() {
|
||||
// 48.8582 N is the Eiffel Tower; the check is that the three-part
|
||||
// form comes back to the same place, to well under a metre.
|
||||
for degrees in [48.8582_f64, -33.8568, 0.0, 179.999] {
|
||||
let parts = dms(degrees);
|
||||
let back = parts[0].0 as f64
|
||||
+ parts[1].0 as f64 / 60.0
|
||||
+ (parts[2].0 as f64 / parts[2].1 as f64) / 3600.0;
|
||||
assert!(
|
||||
(back - degrees.abs()).abs() < 1e-6,
|
||||
"{degrees} came back as {back}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_source_produces_no_block_at_all() {
|
||||
// Every field absent means the only entries would be the ones this
|
||||
// writer adds itself. That is still worth writing — `Software` and
|
||||
// the pixel dimensions are true statements — so the block exists; what
|
||||
// must not happen is a *malformed* one.
|
||||
let md = SourceMetadata::default();
|
||||
let bytes = block(&md, 100, 50).expect("the writer's own tags");
|
||||
assert!(bytes.starts_with(b"II*\0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gps_directory_is_absent_when_there_is_no_position() {
|
||||
let md = SourceMetadata {
|
||||
make: Some("Canon".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = block(&md, 10, 10).unwrap();
|
||||
assert!(!contains_entry(&bytes, tag::GPS_IFD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gps_directory_is_present_when_there_is_one() {
|
||||
// The counterpart of the test above: a strip test that passed because
|
||||
// the writer could never emit GPS at all would prove nothing.
|
||||
let md = SourceMetadata {
|
||||
location: Location::new(48.8582, 2.2945, Some(35.0)),
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = block(&md, 10, 10).unwrap();
|
||||
assert!(contains_entry(&bytes, tag::GPS_IFD));
|
||||
}
|
||||
|
||||
/// Whether a directory entry for `tag` appears anywhere in the block.
|
||||
///
|
||||
/// Byte-level on purpose: an entry is a tag, a type and a count, and
|
||||
/// searching for that twelve-byte shape's first eight bytes is a far
|
||||
/// stronger statement than asking a parser that might have skipped the
|
||||
/// directory the tag was in.
|
||||
fn contains_entry(bytes: &[u8], tag: u16) -> bool {
|
||||
bytes
|
||||
.windows(4)
|
||||
.any(|w| w[..2] == tag.to_le_bytes() && (w[2] == 4 || w[2] == 13) && w[3] == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! TRACES: FR-EXP-2
|
||||
//! Minimal ICC v2 matrix/TRC profiles, generated.
|
||||
//!
|
||||
//! # Why generated rather than shipped
|
||||
//!
|
||||
//! A profile is a description of what the pixels in a file mean, and the
|
||||
//! pixels here were produced by [`dr_types::colour`]'s matrices. Embedding a
|
||||
//! profile downloaded from elsewhere would mean two independent statements
|
||||
//! about the same space, agreeing until one of them was revised. Deriving both
|
||||
//! from the same primaries makes agreement structural.
|
||||
//!
|
||||
//! It is also the only pure-Rust route. Little-CMS is the obvious library and
|
||||
//! it is C, which the whole workspace avoids so it cross-compiles under the
|
||||
//! Android NDK — the same reasoning behind rustls, bundled SQLite and the
|
||||
//! Lensfun port.
|
||||
//!
|
||||
//! # What "minimal" leaves out
|
||||
//!
|
||||
//! A matrix/TRC display profile and nothing else: three colorants, three tone
|
||||
//! curves, a white point and the chromatic adaptation that got it there. No
|
||||
//! A2B/B2A lookup tables, no gamut tag, no named colours. That is the whole of
|
||||
//! what an RGB working space *is*, and it is what every reader — a browser, an
|
||||
//! operating system compositor, Photoshop — takes from a profile like this
|
||||
//! one. The tags omitted describe device behaviour these spaces do not have.
|
||||
//!
|
||||
//! Profiles come out around 2 KB, which matters more than it sounds: a JPEG
|
||||
//! carries the profile in APP2 segments capped at 64 KB each, and one that fits
|
||||
//! in a single segment avoids the chunked form that some older readers
|
||||
//! mishandle.
|
||||
|
||||
use dr_types::{ColourSpace, Transfer};
|
||||
|
||||
/// The ICC profile describing `space`, ready to embed.
|
||||
///
|
||||
/// Deterministic — the same space always produces the same bytes. Two exports
|
||||
/// of the same frame must be byte-identical files, which a creation timestamp
|
||||
/// read from the clock would quietly break, along with any deduplication
|
||||
/// downstream of it.
|
||||
pub fn profile(space: ColourSpace) -> Vec<u8> {
|
||||
let colorants = space.to_pcs_xyz();
|
||||
let trc = trc_curve(space.transfer());
|
||||
|
||||
// Sorted by signature, as the specification asks a tag table to be. Some
|
||||
// readers binary-search it.
|
||||
let mut tags: Vec<(&[u8; 4], Vec<u8>)> = vec![
|
||||
(b"bTRC", trc.clone()),
|
||||
// Columns, not rows: a colorant tag is where one primary lands in XYZ.
|
||||
(b"bXYZ", xyz_type(colorants[2], colorants[5], colorants[8])),
|
||||
(b"cprt", text_type(COPYRIGHT)),
|
||||
(b"desc", description_type(&description(space))),
|
||||
(b"gTRC", trc.clone()),
|
||||
(b"gXYZ", xyz_type(colorants[1], colorants[4], colorants[7])),
|
||||
(b"rTRC", trc),
|
||||
(b"rXYZ", xyz_type(colorants[0], colorants[3], colorants[6])),
|
||||
// The PCS illuminant itself, not the space's own white. The space's
|
||||
// white is recoverable from this and `chad`, and a profile that put
|
||||
// its native white here would have every reader adapt it twice.
|
||||
(b"wtpt", xyz_type(PCS_D50[0], PCS_D50[1], PCS_D50[2])),
|
||||
];
|
||||
|
||||
// Only where there is an adaptation to declare. ProPhoto is a D50 space
|
||||
// already, and an identity `chad` is a tag saying nothing.
|
||||
let adaptation = space.adaptation_to_pcs();
|
||||
if !is_identity(&adaptation) {
|
||||
tags.push((b"chad", sf32_type(&adaptation)));
|
||||
}
|
||||
tags.sort_by_key(|(sig, _)| **sig);
|
||||
|
||||
assemble(&tags)
|
||||
}
|
||||
|
||||
/// What a colour-management dialogue will show this profile as.
|
||||
///
|
||||
/// Deliberately not the canonical names. "sRGB IEC61966-2.1" is the reference
|
||||
/// profile, and this is not it — it is a profile derived from the same
|
||||
/// primaries, which is a different and weaker claim. "Adobe RGB (1998)" is
|
||||
/// additionally a name belonging to someone else. A distinct name also tells a
|
||||
/// user opening the file where the profile came from, which is the question
|
||||
/// they are asking when they look.
|
||||
fn description(space: ColourSpace) -> String {
|
||||
format!("DarkRoom {}", space.label())
|
||||
}
|
||||
|
||||
/// The copyright tag, which ICC requires a profile to carry.
|
||||
///
|
||||
/// A set of chromaticity coordinates from a published specification is not
|
||||
/// something to claim rights over, and a profile nobody may redistribute would
|
||||
/// make the files carrying it awkward to share — which is the whole purpose of
|
||||
/// an export.
|
||||
const COPYRIGHT: &str = "Generated by DarkRoom. No rights reserved.";
|
||||
|
||||
/// The profile connection space illuminant, as s15Fixed16 exactly.
|
||||
const PCS_D50: [f32; 3] = [0.9642, 1.0, 0.8249];
|
||||
|
||||
/// Samples in a tabulated tone curve.
|
||||
///
|
||||
/// 1024 is what the reference sRGB profiles use. The curve is interpolated
|
||||
/// linearly between samples, so this is far finer than the 8-bit values it
|
||||
/// describes; halving it would still be adequate and would save a kilobyte
|
||||
/// nobody is counting.
|
||||
const TRC_SAMPLES: usize = 1024;
|
||||
|
||||
/// A tone reproduction curve for the space's transfer function.
|
||||
///
|
||||
/// ICC curves run *towards* the connection space — device value to linear —
|
||||
/// which is the opposite direction from the shader's final encode. Getting it
|
||||
/// backwards produces a file that looks washed out or crushed by exactly the
|
||||
/// amount the curve bends.
|
||||
fn trc_curve(transfer: Transfer) -> Vec<u8> {
|
||||
// A pure power curve has an exact representation: a single u8Fixed8
|
||||
// gamma. Adobe RGB's 563/256 lands on it precisely, where a 1024-entry
|
||||
// table would be an approximation of a number the format can hold.
|
||||
if let Transfer::Gamma(g) = transfer {
|
||||
let mut out = tag_header(b"curv");
|
||||
out.extend_from_slice(&1u32.to_be_bytes());
|
||||
out.extend_from_slice(&((g * 256.0).round() as u16).to_be_bytes());
|
||||
return out;
|
||||
}
|
||||
|
||||
let mut out = tag_header(b"curv");
|
||||
out.extend_from_slice(&(TRC_SAMPLES as u32).to_be_bytes());
|
||||
for i in 0..TRC_SAMPLES {
|
||||
let device = i as f32 / (TRC_SAMPLES - 1) as f32;
|
||||
let linear = transfer.decode(device);
|
||||
out.extend_from_slice(&((linear * 65535.0).round() as u16).to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An `XYZType` tag: one colour in the connection space.
|
||||
fn xyz_type(x: f32, y: f32, z: f32) -> Vec<u8> {
|
||||
let mut out = tag_header(b"XYZ ");
|
||||
for v in [x, y, z] {
|
||||
out.extend_from_slice(&s15_fixed16(v).to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An `s15Fixed16ArrayType` tag, which is how `chad` is stored.
|
||||
fn sf32_type(m: &[f32; 9]) -> Vec<u8> {
|
||||
let mut out = tag_header(b"sf32");
|
||||
for v in m {
|
||||
out.extend_from_slice(&s15_fixed16(*v).to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A `textType` tag: ASCII with a terminating NUL.
|
||||
fn text_type(s: &str) -> Vec<u8> {
|
||||
let mut out = tag_header(b"text");
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
out.push(0);
|
||||
out
|
||||
}
|
||||
|
||||
/// A `textDescriptionType` tag — the v2 profile's name field.
|
||||
///
|
||||
/// Baroque, and not optional: v2 has no plain `mluc`, and the ASCII string is
|
||||
/// followed by empty Unicode and ScriptCode blocks that a reader will walk
|
||||
/// whether or not they hold anything. The 67-byte Macintosh field is fixed
|
||||
/// width by specification, so it is written out zeroed rather than omitted.
|
||||
fn description_type(s: &str) -> Vec<u8> {
|
||||
let ascii = s.as_bytes();
|
||||
let mut out = tag_header(b"desc");
|
||||
out.extend_from_slice(&(ascii.len() as u32 + 1).to_be_bytes());
|
||||
out.extend_from_slice(ascii);
|
||||
out.push(0);
|
||||
// Unicode language code, then Unicode character count: none of either.
|
||||
out.extend_from_slice(&[0; 8]);
|
||||
// ScriptCode code (u16), length (u8), and the fixed 67-byte field.
|
||||
out.extend_from_slice(&[0; 3]);
|
||||
out.extend_from_slice(&[0; 67]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Every tag element opens with its type signature and four reserved bytes.
|
||||
fn tag_header(sig: &[u8; 4]) -> Vec<u8> {
|
||||
let mut out = Vec::from(*sig);
|
||||
out.extend_from_slice(&[0; 4]);
|
||||
out
|
||||
}
|
||||
|
||||
/// ICC's fixed-point number: 16 integer bits, 16 fractional.
|
||||
fn s15_fixed16(v: f32) -> i32 {
|
||||
(f64::from(v) * 65536.0).round() as i32
|
||||
}
|
||||
|
||||
fn is_identity(m: &[f32; 9]) -> bool {
|
||||
const IDENTITY: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
|
||||
// One step of s15Fixed16, the format the matrix would be stored in. Below
|
||||
// that it *is* the identity — ProPhoto's own white and the PCS illuminant
|
||||
// differ in the sixth decimal place, and a `chad` recording that would be
|
||||
// nine copies of 1.0000 and 0.0000 dressed up as information.
|
||||
const STEP: f32 = 1.0 / 65536.0;
|
||||
m.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < STEP)
|
||||
}
|
||||
|
||||
/// Header, tag table, and the tag data, with the size written back in.
|
||||
fn assemble(tags: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = header();
|
||||
|
||||
out.extend_from_slice(&(tags.len() as u32).to_be_bytes());
|
||||
let table_at = out.len();
|
||||
out.resize(table_at + tags.len() * 12, 0);
|
||||
|
||||
for (i, (sig, data)) in tags.iter().enumerate() {
|
||||
// Identical elements share one copy, which the specification allows
|
||||
// explicitly. The three tone curves of a grey-balanced space are the
|
||||
// same 2 KB table, so this is two thirds of the profile.
|
||||
let offset = find(&out, data).unwrap_or_else(|| {
|
||||
let at = out.len();
|
||||
out.extend_from_slice(data);
|
||||
// Every element starts on a four-byte boundary.
|
||||
while !out.len().is_multiple_of(4) {
|
||||
out.push(0);
|
||||
}
|
||||
at
|
||||
});
|
||||
|
||||
let entry = table_at + i * 12;
|
||||
out[entry..entry + 4].copy_from_slice(*sig);
|
||||
out[entry + 4..entry + 8].copy_from_slice(&(offset as u32).to_be_bytes());
|
||||
out[entry + 8..entry + 12].copy_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
}
|
||||
|
||||
let size = out.len() as u32;
|
||||
out[0..4].copy_from_slice(&size.to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// Where `needle` already sits in `haystack`, if it does.
|
||||
///
|
||||
/// Only ever called with tag elements, which begin on four-byte boundaries and
|
||||
/// start with a type signature — so a match cannot be a coincidental overlap
|
||||
/// of two other tags' bytes.
|
||||
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|w| w == needle)
|
||||
.filter(|at| at.is_multiple_of(4))
|
||||
}
|
||||
|
||||
/// The fixed 128-byte profile header.
|
||||
fn header() -> Vec<u8> {
|
||||
let mut h = Vec::with_capacity(128);
|
||||
// Size, filled in once the profile is complete.
|
||||
h.extend_from_slice(&[0; 4]);
|
||||
// Preferred CMM: no preference.
|
||||
h.extend_from_slice(&[0; 4]);
|
||||
// Version 2.1.0. v2 rather than v4 because it is what every reader
|
||||
// handles, and because nothing here needs a v4 tag type.
|
||||
h.extend_from_slice(&[0x02, 0x10, 0x00, 0x00]);
|
||||
h.extend_from_slice(b"mntr");
|
||||
h.extend_from_slice(b"RGB ");
|
||||
h.extend_from_slice(b"XYZ ");
|
||||
// Creation date. Fixed, for the determinism the module docs describe.
|
||||
for field in [2025u16, 1, 1, 0, 0, 0] {
|
||||
h.extend_from_slice(&field.to_be_bytes());
|
||||
}
|
||||
h.extend_from_slice(b"acsp");
|
||||
// Primary platform, flags, manufacturer, model, attributes: unspecified.
|
||||
h.extend_from_slice(&[0; 24]);
|
||||
// Rendering intent: perceptual, as the reference RGB working-space
|
||||
// profiles declare. For a matrix/TRC profile the field is advisory —
|
||||
// there is only one transform in here to apply.
|
||||
h.extend_from_slice(&[0; 4]);
|
||||
for v in PCS_D50 {
|
||||
h.extend_from_slice(&s15_fixed16(v).to_be_bytes());
|
||||
}
|
||||
// Creator, profile ID, and the reserved tail.
|
||||
h.extend_from_slice(&[0; 4]);
|
||||
h.extend_from_slice(&[0; 16]);
|
||||
h.extend_from_slice(&[0; 28]);
|
||||
debug_assert_eq!(h.len(), 128);
|
||||
h
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A tag's element data, located through the profile's own tag table —
|
||||
/// so these tests read the profile the way a colour engine would rather
|
||||
/// than the way it was written.
|
||||
fn tag<'a>(profile: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> {
|
||||
let count = u32::from_be_bytes(profile[128..132].try_into().unwrap()) as usize;
|
||||
for i in 0..count {
|
||||
let at = 132 + i * 12;
|
||||
if &profile[at..at + 4] == want {
|
||||
let off = u32::from_be_bytes(profile[at + 4..at + 8].try_into().unwrap()) as usize;
|
||||
let len = u32::from_be_bytes(profile[at + 8..at + 12].try_into().unwrap()) as usize;
|
||||
return Some(&profile[off..off + len]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn xyz(data: &[u8]) -> [f32; 3] {
|
||||
let read =
|
||||
|at: usize| i32::from_be_bytes(data[at..at + 4].try_into().unwrap()) as f32 / 65536.0;
|
||||
[read(8), read(12), read(16)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_declares_its_own_length() {
|
||||
// The first field a reader trusts. A profile whose header says it is
|
||||
// longer than the buffer is one a strict parser rejects outright and a
|
||||
// lax one reads past the end of.
|
||||
for space in ColourSpace::ALL {
|
||||
let p = profile(space);
|
||||
let declared = u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize;
|
||||
assert_eq!(declared, p.len(), "{space:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_carries_the_signature_that_identifies_it_as_one() {
|
||||
// `acsp` at offset 36 is how every reader recognises an ICC profile.
|
||||
for space in ColourSpace::ALL {
|
||||
assert_eq!(&profile(space)[36..40], b"acsp", "{space:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_tag_lies_inside_the_profile_and_on_a_boundary() {
|
||||
// A tag table is offsets and lengths, and nothing checks them for us.
|
||||
// An off-by-four here produces a profile that parses as far as the
|
||||
// tag a reader happens to want.
|
||||
for space in ColourSpace::ALL {
|
||||
let p = profile(space);
|
||||
let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize;
|
||||
for i in 0..count {
|
||||
let at = 132 + i * 12;
|
||||
let off = u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap()) as usize;
|
||||
let len = u32::from_be_bytes(p[at + 8..at + 12].try_into().unwrap()) as usize;
|
||||
assert!(off.is_multiple_of(4), "{space:?} tag {i} starts at {off}");
|
||||
assert!(off + len <= p.len(), "{space:?} tag {i} runs off the end");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_profile_carries_the_tags_a_matrix_trc_profile_requires() {
|
||||
// The ICC v2 required set for a display profile. A reader missing any
|
||||
// one of these falls back to assuming sRGB, which is the silent
|
||||
// failure this whole feature exists to prevent.
|
||||
for space in ColourSpace::ALL {
|
||||
let p = profile(space);
|
||||
for required in [
|
||||
b"desc", b"cprt", b"wtpt", b"rXYZ", b"gXYZ", b"bXYZ", b"rTRC", b"gTRC", b"bTRC",
|
||||
] {
|
||||
assert!(tag(&p, required).is_some(), "{space:?} has no {required:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_colorants_are_the_ones_the_shader_encoded_with() {
|
||||
// The property the file's honesty rests on. The composer converts the
|
||||
// pixels with `to_pcs_xyz`'s primaries; if the profile described any
|
||||
// others the file would be a precise, confident lie.
|
||||
for space in ColourSpace::ALL {
|
||||
let p = profile(space);
|
||||
let want = space.to_pcs_xyz();
|
||||
for (i, sig) in [b"rXYZ", b"gXYZ", b"bXYZ"].into_iter().enumerate() {
|
||||
let got = xyz(tag(&p, sig).expect("colorant"));
|
||||
for (row, g) in got.iter().enumerate() {
|
||||
let expected = want[row * 3 + i];
|
||||
assert!(
|
||||
(g - expected).abs() < 1e-4,
|
||||
"{space:?} {sig:?} row {row}: profile says {g}, shader used {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_white_point_is_the_connection_space_illuminant() {
|
||||
// Not the space's own white. ProPhoto's is D50 anyway, but P3's is
|
||||
// D65, and a profile advertising D65 as its media white would have
|
||||
// every neutral adapted a second time.
|
||||
for space in ColourSpace::ALL {
|
||||
let got = xyz(tag(&profile(space), b"wtpt").expect("wtpt"));
|
||||
for (i, want) in PCS_D50.iter().enumerate() {
|
||||
assert!((got[i] - want).abs() < 1e-4, "{space:?} white {i}: {got:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tabulated_curve_reproduces_the_transfer_function_it_came_from() {
|
||||
// Read back out of the profile and compared against the function the
|
||||
// shader encodes with. The curve runs device-to-linear, and writing it
|
||||
// the other way round would still produce a monotonic curve of the
|
||||
// right length — this is what catches the direction.
|
||||
for space in [ColourSpace::Srgb, ColourSpace::ProPhoto] {
|
||||
let p = profile(space);
|
||||
let curve = tag(&p, b"rTRC").expect("rTRC");
|
||||
let count = u32::from_be_bytes(curve[8..12].try_into().unwrap()) as usize;
|
||||
assert_eq!(count, TRC_SAMPLES, "{space:?}");
|
||||
|
||||
let transfer = space.transfer();
|
||||
for i in [0, 1, count / 4, count / 2, count - 1] {
|
||||
let at = 12 + i * 2;
|
||||
let got =
|
||||
f32::from(u16::from_be_bytes(curve[at..at + 2].try_into().unwrap())) / 65535.0;
|
||||
let want = transfer.decode(i as f32 / (count - 1) as f32);
|
||||
assert!(
|
||||
(got - want).abs() < 1e-4,
|
||||
"{space:?} sample {i}: profile {got}, transfer {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adobe_rgb_stores_its_gamma_exactly_rather_than_sampling_it() {
|
||||
// 563/256 is representable in a u8Fixed8, so the curve is one number.
|
||||
// A 1024-entry table would approximate a value the format can hold
|
||||
// exactly, and would round-trip through other software as 2.2.
|
||||
let p = profile(ColourSpace::AdobeRgb);
|
||||
let curve = tag(&p, b"rTRC").expect("rTRC");
|
||||
assert_eq!(u32::from_be_bytes(curve[8..12].try_into().unwrap()), 1);
|
||||
assert_eq!(u16::from_be_bytes(curve[12..14].try_into().unwrap()), 563);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_tone_curves_share_one_copy() {
|
||||
// Not a size optimisation for its own sake: it keeps the profile under
|
||||
// the 64 KB a single JPEG APP2 segment holds, so the chunked form that
|
||||
// older readers mishandle is never needed.
|
||||
let p = profile(ColourSpace::Srgb);
|
||||
let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize;
|
||||
let offsets: Vec<u32> = ["rTRC", "gTRC", "bTRC"]
|
||||
.iter()
|
||||
.map(|sig| {
|
||||
(0..count)
|
||||
.map(|i| 132 + i * 12)
|
||||
.find(|at| &p[*at..at + 4] == sig.as_bytes())
|
||||
.map(|at| u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap()))
|
||||
.expect("curve present")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(offsets[0], offsets[1]);
|
||||
assert_eq!(offsets[1], offsets[2]);
|
||||
assert!(p.len() < 8 * 1024, "{} bytes is too large", p.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_d65_space_declares_its_adaptation_and_a_d50_space_does_not() {
|
||||
// `chad` is what lets a reader recover the space's native white from
|
||||
// colorants that have already been adapted. Without it, D65 primaries
|
||||
// adapted to D50 and genuine D50 primaries are the same nine numbers.
|
||||
assert!(tag(&profile(ColourSpace::DisplayP3), b"chad").is_some());
|
||||
assert!(
|
||||
tag(&profile(ColourSpace::ProPhoto), b"chad").is_none(),
|
||||
"ProPhoto is a D50 space; an identity chad says nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_space_always_produces_the_same_bytes() {
|
||||
// Two exports of one frame must be identical files. A creation
|
||||
// timestamp from the clock is the obvious way to lose that.
|
||||
for space in ColourSpace::ALL {
|
||||
assert_eq!(profile(space), profile(space), "{space:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_space_is_described_by_its_own_name() {
|
||||
// A file whose profile says "sRGB" while carrying P3 pixels is exactly
|
||||
// as misleading as no profile at all, and harder to notice.
|
||||
for space in ColourSpace::ALL {
|
||||
let p = profile(space);
|
||||
let desc = tag(&p, b"desc").expect("desc");
|
||||
let len = u32::from_be_bytes(desc[8..12].try_into().unwrap()) as usize;
|
||||
let name = std::str::from_utf8(&desc[12..12 + len - 1]).expect("ascii");
|
||||
assert_eq!(name, format!("DarkRoom {}", space.label()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9
|
||||
//! Turning a rendered frame into a file's worth of bytes.
|
||||
//!
|
||||
//! # What this crate is, and is not
|
||||
//!
|
||||
//! It is: resize, output sharpening, encode, and the name the result should
|
||||
//! be given. It is not: a filesystem, a network client, or a job queue.
|
||||
//! [`export`] returns [`Encoded`] — bytes and a filename — and the caller
|
||||
//! decides where that lands.
|
||||
//!
|
||||
//! That boundary is not fastidiousness. An export has three possible
|
||||
//! destinations and they have nothing in common: a path on Linux, a Storage
|
||||
//! Access Framework document on Android where there *is* no path
|
||||
//! (ARCH §6.9), and a `PUT` to a Nextcloud folder. A crate that wrote the
|
||||
//! file itself would serve one of them and be rewritten for the other two.
|
||||
//!
|
||||
//! # Order of operations
|
||||
//!
|
||||
//! Resize, then sharpen, then encode. Sharpening after the resize is the
|
||||
//! whole point of output sharpening (FR-EXP-4): it compensates for the
|
||||
//! softening the resample introduced, so its strength has to scale with how
|
||||
//! much scaling actually happened. Sharpening first and then shrinking would
|
||||
//! throw the sharpened detail away.
|
||||
|
||||
use dr_types::{ColourSpace, ExportFormat, ExportSettings};
|
||||
|
||||
mod encode;
|
||||
mod error;
|
||||
mod exif;
|
||||
pub mod icc;
|
||||
mod metadata;
|
||||
mod name;
|
||||
mod sharpen;
|
||||
mod size;
|
||||
|
||||
pub use error::ExportError;
|
||||
pub use metadata::SourceMetadata;
|
||||
pub use name::{resolve_name, NameContext};
|
||||
pub use size::target_size;
|
||||
|
||||
/// A rendered frame, as the adjust pass produced it.
|
||||
///
|
||||
/// 8-bit RGBA, display-encoded in [`Self::space`] — the format
|
||||
/// [`dr_gpu::AdjustPass`](../dr_gpu/struct.AdjustPass.html) writes. Alpha is
|
||||
/// carried but never meaningful: the pipeline writes 1.0 everywhere, and no
|
||||
/// operation produces transparency.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Frame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Tightly packed RGBA8, `width * height * 4` bytes.
|
||||
pub rgba: Vec<u8>,
|
||||
/// TRACES: FR-EXP-2
|
||||
/// The space the shader encoded these pixels into.
|
||||
///
|
||||
/// Travels with the pixels rather than being asserted at the point of
|
||||
/// encoding, because it is a fact about them and not a preference. The
|
||||
/// conversion happened in the generated shader, before the clip to 0..1,
|
||||
/// and nothing downstream can undo or redo it — a frame clipped to sRGB
|
||||
/// has already lost whatever a wider space would have carried.
|
||||
///
|
||||
/// Making it a field is what lets [`export`] refuse to label a frame as
|
||||
/// something it is not, rather than trusting a caller to have rendered
|
||||
/// what it asked for.
|
||||
pub space: ColourSpace,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// A frame the pipeline rendered in sRGB — what
|
||||
/// [`EditGraph::compose`](../dr_pipeline/struct.EditGraph.html#method.compose)
|
||||
/// produces, and so what the display path hands over.
|
||||
///
|
||||
/// An export in a wider space must render its own frame with
|
||||
/// `compose_for` and declare it through [`Self::in_space`]. Defaulting
|
||||
/// here rather than demanding the space at every call site keeps the
|
||||
/// common case honest by construction: a caller that has not thought
|
||||
/// about colour is describing sRGB, and sRGB is what it rendered.
|
||||
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Result<Self, ExportError> {
|
||||
Self::in_space(width, height, rgba, ColourSpace::Srgb)
|
||||
}
|
||||
|
||||
/// A frame rendered into a stated colour space.
|
||||
pub fn in_space(
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba: Vec<u8>,
|
||||
space: ColourSpace,
|
||||
) -> Result<Self, ExportError> {
|
||||
let expected = width as usize * height as usize * 4;
|
||||
if rgba.len() != expected {
|
||||
return Err(ExportError::FrameSize {
|
||||
expected,
|
||||
got: rgba.len(),
|
||||
});
|
||||
}
|
||||
if width == 0 || height == 0 {
|
||||
return Err(ExportError::EmptyFrame);
|
||||
}
|
||||
Ok(Self {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
space,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The finished article: what to write, and what to call it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Encoded {
|
||||
/// Filename including extension. Never a path — the destination folder is
|
||||
/// the caller's, and on Android it is not expressible as one anyway.
|
||||
pub name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
/// What the image was actually written at, after sizing and the upscaling
|
||||
/// guard. Worth reporting: a batch that silently exported at source size
|
||||
/// because the request was larger has done something the user should know.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Resize, sharpen and encode one frame.
|
||||
///
|
||||
/// `name` is the filename already resolved by [`resolve_name`] — passed in
|
||||
/// rather than derived here because resolving it needs to know what is
|
||||
/// already in the destination, which this crate cannot see.
|
||||
///
|
||||
/// TRACES: FR-EXP-9
|
||||
/// The frame is expected to be a **full-resolution** render. Nothing here
|
||||
/// enforces that, because nothing here can tell a full render from a
|
||||
/// viewport-sized one; the caller renders at the framed output size and this
|
||||
/// resamples down from it. Exporting from the display proxy would silently
|
||||
/// produce a soft file, which is why the develop session's export path renders
|
||||
/// its own frame rather than reusing the one on screen.
|
||||
///
|
||||
/// TRACES: FR-EXP-8
|
||||
/// `source` is what the photograph's own file said about itself, or `None`
|
||||
/// where the caller has nothing — a frame that came from somewhere other than
|
||||
/// a decoded file, or a caller that has not yet been taught to pass it.
|
||||
///
|
||||
/// **A parameter rather than a field on [`Frame`]**, because it is not a fact
|
||||
/// about the pixels: two exports of the same frame can legitimately disclose
|
||||
/// different amounts, and the settings that decide how much travel beside it.
|
||||
/// It is also why this is an argument and not an `Option` with a default — a
|
||||
/// caller that has the source metadata should have to decide, in one visible
|
||||
/// place, to hand it over.
|
||||
pub fn export(
|
||||
frame: &Frame,
|
||||
settings: &ExportSettings,
|
||||
name: String,
|
||||
source: Option<&SourceMetadata>,
|
||||
) -> Result<Encoded, ExportError> {
|
||||
// TRACES: FR-EXP-2
|
||||
// Refused rather than mislabelled. Every space the settings page offers
|
||||
// now works, but only if the *frame* was rendered into it: the conversion
|
||||
// and the clip both happen in the generated shader, so pixels that arrive
|
||||
// clipped to sRGB have already lost whatever a wider space would have
|
||||
// carried, and no amount of profile-writing here brings it back.
|
||||
//
|
||||
// The caller's fix is to compose with `EditGraph::compose_for(space)`
|
||||
// before rendering. Until it does, this is an accurate error where the
|
||||
// alternative would be a file that claims a gamut it does not contain —
|
||||
// and that claim survives into everything downstream.
|
||||
if frame.space != settings.colour_space {
|
||||
return Err(ExportError::ColourSpaceMismatch {
|
||||
rendered: frame.space,
|
||||
requested: settings.colour_space,
|
||||
});
|
||||
}
|
||||
|
||||
if matches!(settings.format, ExportFormat::Avif | ExportFormat::JpegXl) {
|
||||
return Err(ExportError::FormatUnsupported(settings.format));
|
||||
}
|
||||
|
||||
let (width, height) = size::target_size(
|
||||
frame.width,
|
||||
frame.height,
|
||||
settings.sizing,
|
||||
settings.allow_upscaling,
|
||||
);
|
||||
|
||||
let resized = size::resample(frame, width, height);
|
||||
|
||||
// Scaled by how much the image actually shrank: a full-size export needs
|
||||
// no compensation, and a thumbnail needs a great deal.
|
||||
let scale = width as f32 / frame.width.max(1) as f32;
|
||||
let sharpened = sharpen::apply(resized, width, height, settings.sharpening, scale);
|
||||
|
||||
let bytes = encode::encode(&sharpened, width, height, settings, source)?;
|
||||
|
||||
Ok(Encoded {
|
||||
name,
|
||||
bytes,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_types::SizingMode;
|
||||
|
||||
/// A frame with a recognisable gradient, so a resample can be checked for
|
||||
/// having done something rather than merely returned the right length.
|
||||
pub(crate) fn frame(w: u32, h: u32) -> Frame {
|
||||
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
rgba.push((x * 255 / w.max(1)) as u8);
|
||||
rgba.push((y * 255 / h.max(1)) as u8);
|
||||
rgba.push(128);
|
||||
rgba.push(255);
|
||||
}
|
||||
}
|
||||
Frame::new(w, h, rgba).expect("well-formed")
|
||||
}
|
||||
|
||||
fn settings(format: ExportFormat) -> ExportSettings {
|
||||
ExportSettings {
|
||||
format,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_rejects_a_buffer_of_the_wrong_length() {
|
||||
// The one error that would otherwise surface as a panic deep in an
|
||||
// encoder, or worse, as a file of garbage.
|
||||
assert!(matches!(
|
||||
Frame::new(4, 4, vec![0; 10]),
|
||||
Err(ExportError::FrameSize { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_export_produces_a_jpeg() {
|
||||
let out = export(
|
||||
&frame(64, 48),
|
||||
&settings(ExportFormat::Jpeg),
|
||||
"a.jpg".into(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
// SOI marker. Cheap, and it catches an encoder wired to the wrong
|
||||
// format far more directly than a byte count would.
|
||||
assert_eq!(&out.bytes[..2], &[0xFF, 0xD8]);
|
||||
assert_eq!((out.width, out.height), (64, 48));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_export_produces_a_png() {
|
||||
let out = export(&frame(32, 32), &settings(ExportFormat::Png), "a.png".into(), None).unwrap();
|
||||
assert_eq!(&out.bytes[..8], b"\x89PNG\r\n\x1a\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiff_exports_produce_a_tiff() {
|
||||
for format in [ExportFormat::Tiff8, ExportFormat::Tiff16] {
|
||||
let out = export(&frame(16, 16), &settings(format), "a.tif".into(), None).unwrap();
|
||||
// Either byte order is a valid TIFF; the crate writes little-endian.
|
||||
assert!(
|
||||
out.bytes.starts_with(b"II*\0") || out.bytes.starts_with(b"MM\0*"),
|
||||
"{format:?} did not produce a TIFF header"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sixteen_bit_tiff_is_larger_than_an_eight_bit_one() {
|
||||
// Both are uncompressed RGB; the only difference is the sample width,
|
||||
// so this is what proves the 16-bit path is not quietly writing 8.
|
||||
let eight = export(&frame(16, 16), &settings(ExportFormat::Tiff8), "a".into(), None).unwrap();
|
||||
let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into(), None).unwrap();
|
||||
assert!(sixteen.bytes.len() > eight.bytes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_changes_the_size_of_a_jpeg() {
|
||||
// The setting is plumbed all the way to the encoder rather than
|
||||
// accepted and dropped, which a size-independent output would show.
|
||||
let mut low = settings(ExportFormat::Jpeg);
|
||||
low.quality = 20;
|
||||
let mut high = settings(ExportFormat::Jpeg);
|
||||
high.quality = 98;
|
||||
|
||||
let small = export(&frame(128, 128), &low, "a".into(), None).unwrap();
|
||||
let large = export(&frame(128, 128), &high, "a".into(), None).unwrap();
|
||||
assert!(
|
||||
large.bytes.len() > small.bytes.len(),
|
||||
"quality 98 produced {} bytes against quality 20's {}",
|
||||
large.bytes.len(),
|
||||
small.bytes.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_edge_export_lands_on_the_requested_size() {
|
||||
let mut s = settings(ExportFormat::Png);
|
||||
s.sizing = SizingMode::LongEdge(32);
|
||||
let out = export(&frame(128, 64), &s, "a".into(), None).unwrap();
|
||||
assert_eq!((out.width, out.height), (32, 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_rendered_in_one_space_is_not_labelled_another() {
|
||||
// A file tagged Display P3 carrying sRGB-clipped pixels is a lie that
|
||||
// survives into everything downstream. The frame carries the space it
|
||||
// was rendered in precisely so this cannot be waved through.
|
||||
let mut s = settings(ExportFormat::Jpeg);
|
||||
s.colour_space = ColourSpace::DisplayP3;
|
||||
assert!(matches!(
|
||||
export(&frame(8, 8), &s, "a".into(), None),
|
||||
Err(ExportError::ColourSpaceMismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_colour_space_exports_when_the_frame_was_rendered_in_it() {
|
||||
// The other side of the refusal above, and what FR-EXP-2 actually
|
||||
// asks for: a frame the pipeline encoded into a wide space reaches a
|
||||
// file, in every format that has an encoder.
|
||||
for space in ColourSpace::ALL {
|
||||
for format in [
|
||||
ExportFormat::Jpeg,
|
||||
ExportFormat::Png,
|
||||
ExportFormat::Tiff8,
|
||||
ExportFormat::Tiff16,
|
||||
] {
|
||||
let mut s = settings(format);
|
||||
s.colour_space = space;
|
||||
let mut f = frame(8, 8);
|
||||
f.space = space;
|
||||
let out = export(&f, &s, "a".into(), None)
|
||||
.unwrap_or_else(|e| panic!("{space:?} as {format:?}: {e}"));
|
||||
assert!(!out.bytes.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formats_without_an_encoder_say_so() {
|
||||
for format in [ExportFormat::Avif, ExportFormat::JpegXl] {
|
||||
assert!(
|
||||
matches!(
|
||||
export(&frame(8, 8), &settings(format), "a".into(), None),
|
||||
Err(ExportError::FormatUnsupported(_))
|
||||
),
|
||||
"{format:?} should report that it has no encoder yet"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_offered_format_either_encodes_or_explains_itself() {
|
||||
// Walks `ExportFormat::ALL`, so a format added to the settings page
|
||||
// cannot quietly reach an encoder that does not handle it.
|
||||
for format in ExportFormat::ALL {
|
||||
match export(&frame(8, 8), &settings(format), "a".into(), None) {
|
||||
Ok(out) => assert!(!out.bytes.is_empty(), "{format:?} encoded to nothing"),
|
||||
Err(ExportError::FormatUnsupported(f)) => assert_eq!(f, format),
|
||||
Err(e) => panic!("{format:?} failed unexpectedly: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! TRACES: FR-EXP-8
|
||||
//! What an export is allowed to say about where it came from.
|
||||
//!
|
||||
//! # An allowlist, not a filter
|
||||
//!
|
||||
//! [`SourceMetadata`] is the whole of what can reach a file this crate writes.
|
||||
//! It is populated field by field from whatever the caller decoded, and
|
||||
//! nothing else travels — not because each unwanted tag is removed, but
|
||||
//! because there is nowhere in this type for one to sit. That is the
|
||||
//! difference between "we strip GPS" and "GPS cannot be written unless
|
||||
//! [`SourceMetadata::location`] is `Some`", and only the second survives
|
||||
//! somebody adding a field to the decoder next year.
|
||||
//!
|
||||
//! # What is deliberately not here
|
||||
//!
|
||||
//! **The maker note** (EXIF `0x927C`). It is an opaque vendor blob with no
|
||||
//! public format, and its contents differ by body and firmware. Canon's
|
||||
//! carries the body serial number and the shutter count; several bodies put a
|
||||
//! *duplicate copy of the GPS fix* inside it, which is the specific reason it
|
||||
//! cannot be passed through as an unexamined byte range: an export that
|
||||
//! stripped the GPS directory and copied the maker note would have published
|
||||
//! the coordinates anyway, while reporting itself as private. Parsing it per
|
||||
//! vendor to decide what is safe is a research project with a permanent
|
||||
//! maintenance cost, and the value on the other side is a few tags a
|
||||
//! photographer rarely misses. So it is dropped, in both directions, whatever
|
||||
//! the settings say.
|
||||
//!
|
||||
//! **Serial numbers and owner name** (`BodySerialNumber` 0xA431,
|
||||
//! `LensSerialNumber` 0xA435, `CameraOwnerName` 0xA430). These identify a
|
||||
//! person and a specific piece of equipment, and a serial number in a
|
||||
//! published file links every photograph that person has ever posted. They
|
||||
//! have no field here, so no export writes them.
|
||||
//!
|
||||
//! **IPTC and XMP.** FR-EXP-8 names both. Neither is read by `dr-decode`
|
||||
//! today, so there is nothing to carry through; when there is, it arrives as
|
||||
//! fields on this type and is written from them, and the same allowlist
|
||||
//! reasoning applies unchanged.
|
||||
|
||||
use dr_types::Location;
|
||||
|
||||
/// TRACES: FR-EXP-8
|
||||
/// The source metadata an export may carry.
|
||||
///
|
||||
/// Every field is optional because every field is genuinely absent from some
|
||||
/// real file: scanner output has no aperture, a JPEG from a phone has no lens
|
||||
/// model, and most photographs have no copyright statement at all.
|
||||
///
|
||||
/// Built by the caller, which is the only place that has both the decoded
|
||||
/// source and the crate that decoded it — `dr-export` deliberately depends on
|
||||
/// no decoder (see the crate docs), so the copy is made one field at a time
|
||||
/// where both types are in scope. That transcription is a feature: it is the
|
||||
/// point where somebody has to decide, in writing, that a newly-parsed piece
|
||||
/// of the source is allowed to leave the machine.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct SourceMetadata {
|
||||
pub make: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub lens: Option<String>,
|
||||
/// Exposure time in seconds.
|
||||
pub shutter: Option<f32>,
|
||||
/// The f-number, as in f/2.8.
|
||||
pub aperture: Option<f32>,
|
||||
pub iso: Option<u32>,
|
||||
/// Millimetres, as marked on the lens rather than 35 mm equivalent.
|
||||
pub focal_length: Option<f32>,
|
||||
/// When the shutter fired, as Unix seconds read as a wall clock.
|
||||
pub captured_at: Option<i64>,
|
||||
/// Minutes east of UTC, where the camera recorded a zone.
|
||||
pub captured_offset: Option<i32>,
|
||||
/// Who made the photograph.
|
||||
pub artist: Option<String>,
|
||||
/// The rights statement.
|
||||
pub copyright: Option<String>,
|
||||
/// TRACES: FR-EXP-8
|
||||
/// Where the shutter fired.
|
||||
///
|
||||
/// The one field the strip option is about. It is carried this far so that
|
||||
/// a photographer who *wants* their coordinates can have them; by the time
|
||||
/// the encoder sees the record this field has already been through
|
||||
/// [`Self::sanitised`], and is `None` unless the user turned stripping
|
||||
/// off.
|
||||
pub location: Option<Location>,
|
||||
}
|
||||
|
||||
impl SourceMetadata {
|
||||
/// This record as the settings permit it to be written.
|
||||
///
|
||||
/// **The single place stripping happens.** The encoders below take a
|
||||
/// record and write what is in it, with no view on privacy; concentrating
|
||||
/// the decision here means there is one function to read to know what an
|
||||
/// export can disclose, and no format can quietly disagree with the
|
||||
/// others — the failure mode where JPEG honours the setting and TIFF, five
|
||||
/// hundred lines away, does not.
|
||||
///
|
||||
/// Stripping empties the field rather than blanking it. A `GPSLatitude` of
|
||||
/// `0/0` still announces that the camera had a fix and that this file has
|
||||
/// been through a scrubber; an absent directory says nothing at all, and
|
||||
/// says it in the same shape as the millions of files that never had one.
|
||||
pub(crate) fn sanitised(&self, strip_location: bool) -> Self {
|
||||
let mut out = self.clone();
|
||||
if strip_location {
|
||||
out.location = None;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! TRACES: FR-EXP-6
|
||||
//! Filename templates and what to do when the name is taken.
|
||||
//!
|
||||
//! # Why the caller supplies the "does this exist" test
|
||||
//!
|
||||
//! [`resolve_name`] takes a closure rather than looking at a directory,
|
||||
//! because there is no directory it could look at that would work everywhere.
|
||||
//! A destination is a path on Linux, a Storage Access Framework tree on
|
||||
//! Android with no path at all (ARCH §6.9), or a folder on a Nextcloud
|
||||
//! server reached by PROPFIND. All three can answer "is this name taken",
|
||||
//! and none of them can be asked the same way.
|
||||
//!
|
||||
//! It matters most on Android, where the platform actively works against us:
|
||||
//! `DocumentsContract.createDocument` renames on collision *by itself*,
|
||||
//! appending ` (1)` and returning a URI with a name nobody asked for, and it
|
||||
//! cannot overwrite at all. So every one of the three [`CollisionPolicy`]
|
||||
//! settings requires knowing the answer before creating anything — which is
|
||||
//! exactly what this function is shaped for.
|
||||
|
||||
use dr_types::{CollisionPolicy, ExportFormat};
|
||||
|
||||
/// What a template can refer to.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NameContext<'a> {
|
||||
/// The source image's name, without extension — `{name}`.
|
||||
pub source_stem: &'a str,
|
||||
/// Position in the batch, 1-based — `{seq}`.
|
||||
pub sequence: u32,
|
||||
/// Capture date as `YYYY-MM-DD` — `{date}`. Empty where unknown.
|
||||
pub date: &'a str,
|
||||
/// The export's pixel dimensions — `{dimensions}`.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// The preset that produced this export — `{preset}`. Empty where none.
|
||||
pub preset: &'a str,
|
||||
}
|
||||
|
||||
/// Expand a template into a filename stem.
|
||||
///
|
||||
/// Unknown tokens are left verbatim rather than dropped. A user who typed
|
||||
/// `{nmae}` should see it in the output and understand what happened; a
|
||||
/// silently empty filename is a puzzle, and a template that quietly loses a
|
||||
/// token produces a directory of files named the same thing.
|
||||
pub fn expand(template: &str, ctx: &NameContext<'_>) -> String {
|
||||
let seq = ctx.sequence.to_string();
|
||||
let dimensions = format!("{}x{}", ctx.width, ctx.height);
|
||||
|
||||
let mut out = String::with_capacity(template.len() + 16);
|
||||
let mut rest = template;
|
||||
while let Some(open) = rest.find('{') {
|
||||
out.push_str(&rest[..open]);
|
||||
let Some(close) = rest[open..].find('}') else {
|
||||
// An unclosed brace is literal text; there is nothing to expand
|
||||
// and dropping the remainder would truncate the name. Consumed
|
||||
// here rather than left for the tail append below, which has
|
||||
// already had everything before the brace taken from it.
|
||||
out.push_str(&rest[open..]);
|
||||
rest = "";
|
||||
break;
|
||||
};
|
||||
let token = &rest[open + 1..open + close];
|
||||
match token {
|
||||
"name" => out.push_str(ctx.source_stem),
|
||||
"seq" => out.push_str(&seq),
|
||||
"date" => out.push_str(ctx.date),
|
||||
"dimensions" => out.push_str(&dimensions),
|
||||
"preset" => out.push_str(ctx.preset),
|
||||
_ => out.push_str(&rest[open..open + close + 1]),
|
||||
}
|
||||
rest = &rest[open + close + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
|
||||
let cleaned = sanitise(&out);
|
||||
if cleaned.is_empty() {
|
||||
// Every token was empty — a template of `{preset}` with no preset, on
|
||||
// an image with no date. Falling back to the source name is the one
|
||||
// answer that is always available and never collides more than the
|
||||
// source files themselves do.
|
||||
return sanitise(ctx.source_stem);
|
||||
}
|
||||
cleaned
|
||||
}
|
||||
|
||||
/// Strip what no filesystem, SAF provider or WebDAV server will take.
|
||||
///
|
||||
/// The intersection of three sets of rules rather than any one of them: an
|
||||
/// export written to a Nextcloud folder may later sync down to a Windows
|
||||
/// client, and a name that was legal where it was created is not much comfort
|
||||
/// on the machine that cannot open it.
|
||||
fn sanitise(stem: &str) -> String {
|
||||
let mut out: String = stem
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
|
||||
c if (c as u32) < 0x20 => '-',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
// Trailing dots and spaces are legal on Linux and rejected by Windows,
|
||||
// and a name ending in one is almost always an accident of a template
|
||||
// whose last token expanded to nothing.
|
||||
while out.ends_with('.') || out.ends_with(' ') {
|
||||
out.pop();
|
||||
}
|
||||
out.trim_start().to_string()
|
||||
}
|
||||
|
||||
/// The filename this export should be written under, honouring the collision
|
||||
/// policy.
|
||||
///
|
||||
/// `taken` answers whether a name already exists in the destination. Returns
|
||||
/// `None` for [`CollisionPolicy::Skip`] when the name is in use — the caller
|
||||
/// writes nothing and moves on, which is the whole point of that setting.
|
||||
pub fn resolve_name(
|
||||
template: &str,
|
||||
ctx: &NameContext<'_>,
|
||||
format: ExportFormat,
|
||||
collision: CollisionPolicy,
|
||||
taken: &dyn Fn(&str) -> bool,
|
||||
) -> Option<String> {
|
||||
let stem = expand(template, ctx);
|
||||
let ext = format.extension();
|
||||
let first = format!("{stem}.{ext}");
|
||||
|
||||
if !taken(&first) {
|
||||
return Some(first);
|
||||
}
|
||||
|
||||
match collision {
|
||||
CollisionPolicy::Overwrite => Some(first),
|
||||
CollisionPolicy::Skip => None,
|
||||
CollisionPolicy::Increment => {
|
||||
// Bounded. An unbounded search would spin forever against a
|
||||
// destination that reports everything as taken — a permission
|
||||
// error misread as existence, say — and a batch that hangs is
|
||||
// worse than one that reports a failure.
|
||||
for n in 1..10_000 {
|
||||
let candidate = format!("{stem}-{n}.{ext}");
|
||||
if !taken(&candidate) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
log::warn!("{stem}: ten thousand names taken; skipping");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx() -> NameContext<'static> {
|
||||
NameContext {
|
||||
source_stem: "IMG_1234",
|
||||
sequence: 7,
|
||||
date: "2026-08-16",
|
||||
width: 2048,
|
||||
height: 1365,
|
||||
preset: "Web",
|
||||
}
|
||||
}
|
||||
|
||||
fn free(_: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_template_is_the_source_name() {
|
||||
assert_eq!(expand("{name}", &ctx()), "IMG_1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_documented_token_expands() {
|
||||
// The settings page advertises these five in its hint; a token listed
|
||||
// there and unhandled here would reach the filename verbatim.
|
||||
assert_eq!(expand("{name}", &ctx()), "IMG_1234");
|
||||
assert_eq!(expand("{seq}", &ctx()), "7");
|
||||
assert_eq!(expand("{date}", &ctx()), "2026-08-16");
|
||||
assert_eq!(expand("{dimensions}", &ctx()), "2048x1365");
|
||||
assert_eq!(expand("{preset}", &ctx()), "Web");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_combine_with_literal_text() {
|
||||
assert_eq!(
|
||||
expand("{date}_{name}_{dimensions}", &ctx()),
|
||||
"2026-08-16_IMG_1234_2048x1365"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_token_survives_verbatim() {
|
||||
// A typo the user can see and fix, rather than a name that silently
|
||||
// lost a component and now collides with every other export.
|
||||
assert_eq!(expand("{nmae}-x", &ctx()), "{nmae}-x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclosed_brace_is_literal_text() {
|
||||
assert_eq!(expand("{name", &ctx()), "{name");
|
||||
assert_eq!(expand("a{name}b{", &ctx()), "aIMG_1234b{");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_that_expands_to_nothing_falls_back_to_the_source_name() {
|
||||
// `{preset}` with no preset selected. An empty filename is not a file.
|
||||
let mut c = ctx();
|
||||
c.preset = "";
|
||||
assert_eq!(expand("{preset}", &c), "IMG_1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_separators_cannot_escape_the_destination() {
|
||||
// `{name}` comes from a source filename, and a template is user text.
|
||||
// Either could carry a slash, and an export must not write outside
|
||||
// the folder that was chosen — nor create a subfolder on the server.
|
||||
let mut c = ctx();
|
||||
c.source_stem = "holiday/2026";
|
||||
assert_eq!(expand("{name}", &c), "holiday-2026");
|
||||
assert_eq!(expand("../../etc/passwd", &ctx()), "..-..-etc-passwd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn characters_windows_rejects_are_replaced() {
|
||||
// An export may sync down to a Windows client through Nextcloud, and
|
||||
// a name that was legal where it was written is no comfort there.
|
||||
assert_eq!(expand(r#"a:b*c?d"e<f>g|h\i"#, &ctx()), "a-b-c-d-e-f-g-h-i");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_dots_and_spaces_are_trimmed() {
|
||||
let mut c = ctx();
|
||||
c.preset = "";
|
||||
assert_eq!(expand("{name}.{preset}", &c), "IMG_1234");
|
||||
assert_eq!(expand("{name} ", &ctx()), "IMG_1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_free_name_is_used_as_is() {
|
||||
let got = resolve_name(
|
||||
"{name}",
|
||||
&ctx(),
|
||||
ExportFormat::Jpeg,
|
||||
CollisionPolicy::Increment,
|
||||
&free,
|
||||
);
|
||||
assert_eq!(got.as_deref(), Some("IMG_1234.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_extension_follows_the_format() {
|
||||
for (format, ext) in [
|
||||
(ExportFormat::Jpeg, "jpg"),
|
||||
(ExportFormat::Png, "png"),
|
||||
(ExportFormat::Tiff16, "tif"),
|
||||
] {
|
||||
let got = resolve_name("{name}", &ctx(), format, CollisionPolicy::Skip, &free);
|
||||
assert_eq!(got.as_deref(), Some(&*format!("IMG_1234.{ext}")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increment_finds_the_first_free_suffix() {
|
||||
let taken = |n: &str| matches!(n, "IMG_1234.jpg" | "IMG_1234-1.jpg" | "IMG_1234-2.jpg");
|
||||
let got = resolve_name(
|
||||
"{name}",
|
||||
&ctx(),
|
||||
ExportFormat::Jpeg,
|
||||
CollisionPolicy::Increment,
|
||||
&taken,
|
||||
);
|
||||
assert_eq!(got.as_deref(), Some("IMG_1234-3.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_returns_nothing_when_the_name_is_taken() {
|
||||
// The caller writes no file at all — that is what Skip means, and it
|
||||
// is why this returns an Option rather than always a name.
|
||||
let got = resolve_name(
|
||||
"{name}",
|
||||
&ctx(),
|
||||
ExportFormat::Jpeg,
|
||||
CollisionPolicy::Skip,
|
||||
&|_| true,
|
||||
);
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_returns_the_taken_name() {
|
||||
let got = resolve_name(
|
||||
"{name}",
|
||||
&ctx(),
|
||||
ExportFormat::Jpeg,
|
||||
CollisionPolicy::Overwrite,
|
||||
&|_| true,
|
||||
);
|
||||
assert_eq!(got.as_deref(), Some("IMG_1234.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increment_gives_up_rather_than_spinning_forever() {
|
||||
// A destination that reports every name as taken — a permission error
|
||||
// misread as existence — must not hang the batch.
|
||||
let got = resolve_name(
|
||||
"{name}",
|
||||
&ctx(),
|
||||
ExportFormat::Jpeg,
|
||||
CollisionPolicy::Increment,
|
||||
&|_| true,
|
||||
);
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! TRACES: FR-EXP-4
|
||||
//! Output sharpening, scaled by how far the image was resized.
|
||||
//!
|
||||
//! # Why an export needs this at all
|
||||
//!
|
||||
//! Downsampling averages neighbouring pixels, and averaging is a low-pass
|
||||
//! filter: a 24 MP frame reduced to 2048px comes out measurably softer than
|
||||
//! the same scene shot at 2048px would be. Output sharpening puts back the
|
||||
//! acuity the resample removed. It is not creative sharpening — that belongs
|
||||
//! in the develop pipeline, acts on the full-resolution image, and is a
|
||||
//! different control entirely.
|
||||
//!
|
||||
//! # Why the strength depends on the medium
|
||||
//!
|
||||
//! The three settings are not intensities dressed up as names. A screen shows
|
||||
//! a pixel as a pixel, so it needs the least. Ink spreads into paper — dot
|
||||
//! gain — and matte stock spreads it further than glossy, so a print needs
|
||||
//! more compensation to arrive looking the same. That is why the paper
|
||||
//! options are stronger, and why "more" is not simply a slider.
|
||||
|
||||
use dr_types::OutputSharpening;
|
||||
|
||||
/// Radius of the unsharp mask, in pixels.
|
||||
///
|
||||
/// Fixed at a small value rather than scaled with the image: output
|
||||
/// sharpening compensates for the *resample*, which softens over a pixel or
|
||||
/// two whatever the size of the frame. A radius that grew with the image
|
||||
/// would produce haloes on a large export.
|
||||
const RADIUS: i32 = 1;
|
||||
|
||||
/// Per-setting strength. Applied on top of the resize-derived scaling below.
|
||||
fn strength(setting: OutputSharpening) -> f32 {
|
||||
match setting {
|
||||
OutputSharpening::None => 0.0,
|
||||
OutputSharpening::Screen => 0.55,
|
||||
// Ink spread. Matte stock absorbs more than glossy, so it needs the
|
||||
// heavier hand of the two.
|
||||
OutputSharpening::GlossyPaper => 0.85,
|
||||
OutputSharpening::MattePaper => 1.15,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sharpen in place-ish: takes the resized buffer and returns it, sharpened.
|
||||
///
|
||||
/// `scale` is the resize factor — destination width over source width. Below
|
||||
/// 1 the image was reduced and needs compensation; at or above 1 nothing was
|
||||
/// averaged away and the sharpening is skipped, because sharpening an image
|
||||
/// that was not softened only adds haloes.
|
||||
pub fn apply(
|
||||
mut rgba: Vec<u8>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
setting: OutputSharpening,
|
||||
scale: f32,
|
||||
) -> Vec<u8> {
|
||||
let base = strength(setting);
|
||||
if base == 0.0 || scale >= 1.0 || width < 3 || height < 3 {
|
||||
return rgba;
|
||||
}
|
||||
|
||||
// A frame reduced to a tenth lost far more than one reduced to nine
|
||||
// tenths, so the compensation follows the reduction. Capped at the base
|
||||
// strength: past a point more sharpening is just edge artefacts, and a
|
||||
// thumbnail is the case where that shows most.
|
||||
let amount = base * (1.0 - scale).clamp(0.0, 1.0);
|
||||
|
||||
let src = rgba.clone();
|
||||
let (w, h) = (width as i32, height as i32);
|
||||
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
for c in 0..3 {
|
||||
// A 3×3 box blur is the mask. Gaussian would be more correct
|
||||
// and, at radius 1, indistinguishable — the kernel is nine
|
||||
// pixels either way.
|
||||
let mut sum = 0.0f32;
|
||||
let mut n = 0.0f32;
|
||||
for dy in -RADIUS..=RADIUS {
|
||||
for dx in -RADIUS..=RADIUS {
|
||||
let sx = (x + dx).clamp(0, w - 1);
|
||||
let sy = (y + dy).clamp(0, h - 1);
|
||||
sum += f32::from(src[((sy * w + sx) * 4 + c) as usize]);
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
let blurred = sum / n;
|
||||
let p = ((y * w + x) * 4 + c) as usize;
|
||||
let original = f32::from(src[p]);
|
||||
// Unsharp mask: the original plus its difference from a
|
||||
// blurred copy, which is the high-frequency detail.
|
||||
let sharpened = original + (original - blurred) * amount;
|
||||
rgba[p] = sharpened.round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
rgba
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A frame split down the middle: dark left, light right. One vertical
|
||||
/// edge, which is what sharpening acts on.
|
||||
fn edge(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
for _ in 0..h {
|
||||
for x in 0..w {
|
||||
let level = if x < w / 2 { 60 } else { 190 };
|
||||
v.extend_from_slice(&[level, level, level, 255]);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn at(buf: &[u8], w: u32, x: u32, y: u32) -> u8 {
|
||||
buf[((y * w + x) * 4) as usize]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_leaves_the_image_exactly_as_it_was() {
|
||||
let src = edge(16, 8);
|
||||
let out = apply(src.clone(), 16, 8, OutputSharpening::None, 0.5);
|
||||
assert_eq!(out, src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unresized_export_is_not_sharpened() {
|
||||
// Nothing was averaged away, so there is nothing to compensate for
|
||||
// and sharpening would only add haloes.
|
||||
let src = edge(16, 8);
|
||||
assert_eq!(
|
||||
apply(src.clone(), 16, 8, OutputSharpening::MattePaper, 1.0),
|
||||
src
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharpening_increases_contrast_across_an_edge() {
|
||||
// The property, stated directly: the dark side of the edge gets
|
||||
// darker and the light side lighter.
|
||||
let src = edge(16, 8);
|
||||
let out = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.4);
|
||||
let (before_dark, before_light) = (at(&src, 16, 7, 4), at(&src, 16, 8, 4));
|
||||
let (after_dark, after_light) = (at(&out, 16, 7, 4), at(&out, 16, 8, 4));
|
||||
assert!(after_dark < before_dark, "the dark side should deepen");
|
||||
assert!(after_light > before_light, "the light side should lift");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_sharpens_harder_than_screen() {
|
||||
// Ink spreads; the settings are about the medium, not taste.
|
||||
let src = edge(16, 8);
|
||||
let screen = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.4);
|
||||
let matte = apply(src.clone(), 16, 8, OutputSharpening::MattePaper, 0.4);
|
||||
assert!(at(&matte, 16, 8, 4) > at(&screen, 16, 8, 4));
|
||||
assert!(strength(OutputSharpening::MattePaper) > strength(OutputSharpening::GlossyPaper));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bigger_reduction_sharpens_more() {
|
||||
let src = edge(16, 8);
|
||||
let mild = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.9);
|
||||
let severe = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.1);
|
||||
assert!(at(&severe, 16, 8, 4) >= at(&mild, 16, 8, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_field_is_untouched() {
|
||||
// No detail means no high frequencies to amplify. If this drifts, the
|
||||
// mask is not centred and every sky gains a gradient.
|
||||
let flat = vec![128u8; 16 * 16 * 4];
|
||||
assert_eq!(
|
||||
apply(flat.clone(), 16, 16, OutputSharpening::MattePaper, 0.3),
|
||||
flat
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_is_never_touched() {
|
||||
// The loop runs over three channels for a reason: sharpening alpha
|
||||
// would put a halo in the transparency of an image that has none.
|
||||
let out = apply(edge(16, 8), 16, 8, OutputSharpening::MattePaper, 0.2);
|
||||
for px in out.chunks_exact(4) {
|
||||
assert_eq!(px[3], 255);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_too_small_to_have_neighbours_is_left_alone() {
|
||||
let tiny = vec![10u8; 2 * 2 * 4];
|
||||
assert_eq!(
|
||||
apply(tiny.clone(), 2, 2, OutputSharpening::Screen, 0.5),
|
||||
tiny
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! TRACES: FR-EXP-3 | FR-EXP-4
|
||||
//! Output sizing and resampling.
|
||||
//!
|
||||
//! # Why Lanczos
|
||||
//!
|
||||
//! FR-EXP-4 asks for "a quality resampler (Lanczos or better)", and the
|
||||
//! reason is what a cheap one does to a photograph. Box or bilinear
|
||||
//! downsampling of a 24 MP frame to 2048px averages away detail the sensor
|
||||
//! resolved and aliases what is left — a brick wall or a distant fence comes
|
||||
//! back as moiré. Lanczos's negative lobes preserve edge acuity through a
|
||||
//! large reduction, which is exactly the operation an export performs.
|
||||
//!
|
||||
//! Separable: a horizontal pass then a vertical one, which turns an `a²`
|
||||
//! kernel into `2a` taps per pixel. At the sizes involved that is the
|
||||
//! difference between an export that feels instant and one that does not.
|
||||
|
||||
use dr_types::SizingMode;
|
||||
|
||||
use crate::Frame;
|
||||
|
||||
/// The Lanczos window. 3 is the photographic default — 2 is softer, and
|
||||
/// beyond 3 the extra lobes buy ringing rather than detail.
|
||||
const A: f32 = 3.0;
|
||||
|
||||
/// TRACES: FR-EXP-3
|
||||
/// Resolve the requested sizing against a source, honouring the upscale rule.
|
||||
///
|
||||
/// Aspect is preserved in every mode, so only one dimension is ever the
|
||||
/// requested one.
|
||||
///
|
||||
/// **Upscaling is refused by clamping, never by failing.** FR-EXP-3 makes
|
||||
/// upscaling opt-in, and a batch of mixed frames must not abort because one
|
||||
/// was smaller than the target — the user asked for a set of exports, and
|
||||
/// stopping the run over a frame that came out at source size would be a
|
||||
/// worse answer than the file itself.
|
||||
pub fn target_size(
|
||||
src_w: u32,
|
||||
src_h: u32,
|
||||
sizing: SizingMode,
|
||||
allow_upscaling: bool,
|
||||
) -> (u32, u32) {
|
||||
let (src_w, src_h) = (src_w.max(1), src_h.max(1));
|
||||
|
||||
let (w, h) = match sizing {
|
||||
SizingMode::Original => (src_w, src_h),
|
||||
SizingMode::LongEdge(n) => scale_to(src_w, src_h, n, src_w >= src_h),
|
||||
SizingMode::ShortEdge(n) => scale_to(src_w, src_h, n, src_w < src_h),
|
||||
SizingMode::Percentage(p) => {
|
||||
let f = f64::from(p) / 100.0;
|
||||
(
|
||||
((f64::from(src_w) * f).round() as u32).max(1),
|
||||
((f64::from(src_h) * f).round() as u32).max(1),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if !allow_upscaling && (w > src_w || h > src_h) {
|
||||
return (src_w, src_h);
|
||||
}
|
||||
(w.max(1), h.max(1))
|
||||
}
|
||||
|
||||
/// Scale so that the chosen edge lands on `n`.
|
||||
fn scale_to(src_w: u32, src_h: u32, n: u32, width_is_the_edge: bool) -> (u32, u32) {
|
||||
let n = n.max(1);
|
||||
if width_is_the_edge {
|
||||
let h = (f64::from(n) * f64::from(src_h) / f64::from(src_w)).round() as u32;
|
||||
(n, h.max(1))
|
||||
} else {
|
||||
let w = (f64::from(n) * f64::from(src_w) / f64::from(src_h)).round() as u32;
|
||||
(w.max(1), n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resample to `(dst_w, dst_h)`, returning tightly packed RGBA8.
|
||||
///
|
||||
/// Returns the source buffer untouched where no scaling is needed, which is
|
||||
/// the `SizingMode::Original` case and therefore the common one.
|
||||
pub fn resample(frame: &Frame, dst_w: u32, dst_h: u32) -> Vec<u8> {
|
||||
if dst_w == frame.width && dst_h == frame.height {
|
||||
return frame.rgba.clone();
|
||||
}
|
||||
|
||||
// Horizontal, then vertical. The intermediate is the destination width by
|
||||
// the *source* height, so the second pass works on as little data as the
|
||||
// first can leave it.
|
||||
let horizontal = pass(
|
||||
&frame.rgba,
|
||||
frame.width,
|
||||
frame.height,
|
||||
dst_w,
|
||||
frame.height,
|
||||
true,
|
||||
);
|
||||
pass(&horizontal, dst_w, frame.height, dst_w, dst_h, false)
|
||||
}
|
||||
|
||||
/// One separable pass. `horizontal` picks the axis being resampled.
|
||||
fn pass(src: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32, horizontal: bool) -> Vec<u8> {
|
||||
let (src_len, dst_len) = if horizontal {
|
||||
(src_w, dst_w)
|
||||
} else {
|
||||
(src_h, dst_h)
|
||||
};
|
||||
let ratio = f64::from(src_len) / f64::from(dst_len);
|
||||
|
||||
// Enlarging samples the source at its own frequency; shrinking has to
|
||||
// widen the kernel to average the pixels being discarded, or the result
|
||||
// aliases. This is the whole difference between a resample and a
|
||||
// subsample.
|
||||
let filter_scale = ratio.max(1.0);
|
||||
let support = A as f64 * filter_scale;
|
||||
|
||||
let mut out = vec![0u8; (dst_w * dst_h * 4) as usize];
|
||||
|
||||
for i in 0..dst_len {
|
||||
// Centre of the destination sample, in source coordinates.
|
||||
let centre = (f64::from(i) + 0.5) * ratio - 0.5;
|
||||
let first = ((centre - support).ceil() as i64).max(0);
|
||||
let last = ((centre + support).floor() as i64).min(i64::from(src_len) - 1);
|
||||
|
||||
// Weights once per output row/column rather than per pixel: they
|
||||
// depend only on the axis position, and recomputing them per channel
|
||||
// was most of the cost when this was written the obvious way.
|
||||
let mut weights = Vec::with_capacity((last - first + 1).max(0) as usize);
|
||||
let mut total = 0.0f64;
|
||||
for s in first..=last {
|
||||
let w = lanczos((f64::from(s as i32) - centre) / filter_scale);
|
||||
weights.push(w);
|
||||
total += w;
|
||||
}
|
||||
if total == 0.0 {
|
||||
total = 1.0;
|
||||
}
|
||||
|
||||
let other = if horizontal { dst_h } else { dst_w };
|
||||
for j in 0..other {
|
||||
let mut acc = [0.0f64; 4];
|
||||
for (k, w) in weights.iter().enumerate() {
|
||||
let s = first as u32 + k as u32;
|
||||
let (x, y) = if horizontal { (s, j) } else { (j, s) };
|
||||
let p = ((y * src_w + x) * 4) as usize;
|
||||
for c in 0..4 {
|
||||
acc[c] += f64::from(src[p + c]) * w;
|
||||
}
|
||||
}
|
||||
let (x, y) = if horizontal { (i, j) } else { (j, i) };
|
||||
let p = ((y * dst_w + x) * 4) as usize;
|
||||
for c in 0..4 {
|
||||
// Lanczos overshoots at edges — that is what makes it look
|
||||
// sharp — so the result must be clamped rather than wrapped.
|
||||
out[p + c] = (acc[c] / total).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The Lanczos kernel, `sinc(x) * sinc(x / a)`.
|
||||
fn lanczos(x: f64) -> f64 {
|
||||
let x = x.abs();
|
||||
if x < 1e-9 {
|
||||
return 1.0;
|
||||
}
|
||||
if x >= f64::from(A) {
|
||||
return 0.0;
|
||||
}
|
||||
let px = std::f64::consts::PI * x;
|
||||
(px.sin() / px) * ((px / f64::from(A)).sin() / (px / f64::from(A)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tests::frame;
|
||||
|
||||
#[test]
|
||||
fn original_is_the_source_size() {
|
||||
assert_eq!(
|
||||
target_size(6000, 4000, SizingMode::Original, false),
|
||||
(6000, 4000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_edge_picks_the_longer_dimension_either_way_round() {
|
||||
assert_eq!(
|
||||
target_size(6000, 4000, SizingMode::LongEdge(3000), false),
|
||||
(3000, 2000)
|
||||
);
|
||||
// Portrait: the long edge is now the height.
|
||||
assert_eq!(
|
||||
target_size(4000, 6000, SizingMode::LongEdge(3000), false),
|
||||
(2000, 3000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_edge_picks_the_shorter_dimension_either_way_round() {
|
||||
assert_eq!(
|
||||
target_size(6000, 4000, SizingMode::ShortEdge(2000), false),
|
||||
(3000, 2000)
|
||||
);
|
||||
assert_eq!(
|
||||
target_size(4000, 6000, SizingMode::ShortEdge(2000), false),
|
||||
(2000, 3000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_percentage_scales_both_dimensions() {
|
||||
assert_eq!(
|
||||
target_size(4000, 3000, SizingMode::Percentage(50), false),
|
||||
(2000, 1500)
|
||||
);
|
||||
assert_eq!(
|
||||
target_size(4000, 3000, SizingMode::Percentage(100), false),
|
||||
(4000, 3000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upscaling_is_refused_by_clamping_rather_than_failing() {
|
||||
// FR-EXP-3: opt-in, and a batch must not abort over one small frame.
|
||||
assert_eq!(
|
||||
target_size(800, 600, SizingMode::LongEdge(4000), false),
|
||||
(800, 600)
|
||||
);
|
||||
assert_eq!(
|
||||
target_size(800, 600, SizingMode::Percentage(400), false),
|
||||
(800, 600)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upscaling_is_honoured_when_asked_for() {
|
||||
assert_eq!(
|
||||
target_size(800, 600, SizingMode::LongEdge(1600), true),
|
||||
(1600, 1200)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_square_frame_treats_either_edge_as_the_long_one() {
|
||||
// The tie has to resolve somewhere, and both answers are the same
|
||||
// size — but it must not produce a zero or a panic.
|
||||
assert_eq!(
|
||||
target_size(1000, 1000, SizingMode::LongEdge(500), false),
|
||||
(500, 500)
|
||||
);
|
||||
assert_eq!(
|
||||
target_size(1000, 1000, SizingMode::ShortEdge(500), false),
|
||||
(500, 500)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_size_can_never_round_down_to_nothing() {
|
||||
// A 1% export of a small frame rounds toward zero, and a zero-pixel
|
||||
// image is not a file anyone can open.
|
||||
let (w, h) = target_size(50, 30, SizingMode::Percentage(1), false);
|
||||
assert!(w >= 1 && h >= 1, "got {w}x{h}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampling_to_the_same_size_changes_nothing() {
|
||||
// The `Original` path, which is the common one — it must not spend a
|
||||
// Lanczos pass to return what it was given.
|
||||
let f = frame(32, 24);
|
||||
assert_eq!(resample(&f, 32, 24), f.rgba);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resample_produces_the_right_number_of_pixels() {
|
||||
let f = frame(64, 48);
|
||||
assert_eq!(resample(&f, 32, 24).len(), 32 * 24 * 4);
|
||||
assert_eq!(resample(&f, 100, 75).len(), 100 * 75 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_downscale_preserves_the_gradient_it_was_given() {
|
||||
// The check that separates a real resample from a buffer of the right
|
||||
// length: the test frame ramps red left-to-right, so the output must
|
||||
// too, and its corners must still be near the source's.
|
||||
let f = frame(128, 128);
|
||||
let small = resample(&f, 32, 32);
|
||||
let px = |x: usize, y: usize| small[(y * 32 + x) * 4];
|
||||
assert!(px(0, 0) < px(16, 0), "red should rise across the frame");
|
||||
assert!(px(16, 0) < px(31, 0));
|
||||
// Row-invariant in red, since the ramp is horizontal.
|
||||
assert!((i32::from(px(16, 0)) - i32::from(px(16, 31))).abs() < 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_field_survives_a_resample_unchanged() {
|
||||
// Lanczos rings on edges, which is intended — but a constant field
|
||||
// has no edges, and any deviation here means the weights do not sum
|
||||
// to one. That error is invisible on a photograph and glaring on a
|
||||
// sky.
|
||||
let flat = Frame::new(64, 64, vec![200; 64 * 64 * 4]).unwrap();
|
||||
for byte in resample(&flat, 21, 21) {
|
||||
assert_eq!(byte, 200, "a constant field must resample to itself");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upscale_also_holds_a_flat_field() {
|
||||
let flat = Frame::new(16, 16, vec![64; 16 * 16 * 4]).unwrap();
|
||||
for byte in resample(&flat, 40, 40) {
|
||||
assert_eq!(byte, 64);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kernel_is_one_at_the_centre_and_zero_past_its_window() {
|
||||
assert!((lanczos(0.0) - 1.0).abs() < 1e-9);
|
||||
assert_eq!(lanczos(3.0), 0.0);
|
||||
assert_eq!(lanczos(4.5), 0.0);
|
||||
// Zero at the integers inside the window, which is what makes an
|
||||
// unscaled resample an identity.
|
||||
assert!(lanczos(1.0).abs() < 1e-9);
|
||||
assert!(lanczos(2.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
[package]
|
||||
name = "dr-gpu"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
dr-decode.workspace = true
|
||||
dr-pipeline.workspace = true
|
||||
# The watershed's pixel passes are here because they are shaders; everything
|
||||
# that reasons about regions rather than pixels lives there, where it is
|
||||
# testable with no adapter present. No features: this half needs neither the
|
||||
# inference runtime nor the weights, and the workspace declaration defaults
|
||||
# them off so that stays true.
|
||||
dr-segment.workspace = true
|
||||
wgpu.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
bytemuck.workspace = true
|
||||
# Needed outside tests: shader compilation errors are collected through an
|
||||
# async error scope, which must be resolved before the pipeline is returned.
|
||||
pollster.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger.workspace = true
|
||||
# The detail stage's test consumer — a box blur that is not a develop operation
|
||||
# and never reaches the panel. An abstraction with no consumers is a guess, and
|
||||
# this is the one that proves the neighbourhood passes compile, ping-pong,
|
||||
# encode once, and scale between a proxy and an export. A dev-dependency, so a
|
||||
# shipping `dr-gpu` does not carry it.
|
||||
dr-pipeline = { workspace = true, features = ["detail-probe"] }
|
||||
# The local-adjustment example needs the model, which the library half of this
|
||||
# crate deliberately does not: `dr-gpu` holds the shaders, and the inference
|
||||
# runtime belongs to whoever is asking a question about the picture.
|
||||
dr-segment = { workspace = true, features = ["semantic", "embedded-model"] }
|
||||
|
||||
[[example]]
|
||||
name = "bench"
|
||||
required-features = ["readback"]
|
||||
|
||||
[[example]]
|
||||
name = "develop"
|
||||
# No `readback` needed since S1: this writes a file, so it goes through
|
||||
# `export_pixels`, which is ungated precisely because an export is not the
|
||||
# round-trip AC-8 forbids.
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Exposes read_pixels outside tests. Production must not enable this.
|
||||
readback = []
|
||||
# Exposes `Segmentation::read_field`, which builds the region adjacency graph
|
||||
# on the CPU. Separate from `readback` on purpose — see `segment.rs`. Once per
|
||||
# image on a worker, not the per-frame display round-trip AC-8 forbids; still a
|
||||
# full-resolution transfer, and still F3's open gap.
|
||||
segment-readback = []
|
||||
|
||||
[[example]]
|
||||
name = "segment"
|
||||
required-features = ["segment-readback"]
|
||||
|
||||
[[example]]
|
||||
name = "local"
|
||||
# No `segment-readback`: this reads the *rendered* proxy back through
|
||||
# `export_pixels` to feed the model, which is the ungated export path. The
|
||||
# watershed, and the region-graph transfer that needs the gate, is not involved.
|
||||
|
||||
|
||||
[[test]]
|
||||
name = "masked_outputs"
|
||||
# Needs the distance transform, which lives with the model half of dr-segment.
|
||||
required-features = []
|
||||
@@ -0,0 +1,55 @@
|
||||
// Measures the per-frame cost at several canvas sizes, isolating the readback
|
||||
// path from windowing.
|
||||
use dr_gpu::{GpuContext, RenderTarget};
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let ctx = pollster::block_on(GpuContext::new_headless()).unwrap();
|
||||
println!("adapter: {}\n", ctx.adapter_name());
|
||||
println!(
|
||||
"{:>12} {:>10} {:>10} {:>8}",
|
||||
"size", "compute", "+readback", "fps"
|
||||
);
|
||||
|
||||
for &(w, h) in &[
|
||||
(840u32, 692u32),
|
||||
(1280, 720),
|
||||
(1920, 1080),
|
||||
(2048, 1152),
|
||||
(3840, 2160),
|
||||
] {
|
||||
let rt = RenderTarget::new(&ctx, w, h).unwrap();
|
||||
// warm
|
||||
for i in 0..10 {
|
||||
rt.render(i as f32 * 0.01);
|
||||
let _ = pollster::block_on(rt.read_pixels());
|
||||
}
|
||||
|
||||
let n = 40;
|
||||
let t0 = Instant::now();
|
||||
for i in 0..n {
|
||||
rt.render(i as f32 * 0.01);
|
||||
}
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
let compute = t0.elapsed().as_secs_f64() / n as f64;
|
||||
|
||||
let t1 = Instant::now();
|
||||
for i in 0..n {
|
||||
rt.render(i as f32 * 0.01);
|
||||
let _ = pollster::block_on(rt.read_pixels()).unwrap();
|
||||
}
|
||||
let full = t1.elapsed().as_secs_f64() / n as f64;
|
||||
|
||||
println!(
|
||||
"{:>5}x{:<6} {:>8.2}ms {:>8.2}ms {:>8.0}",
|
||||
w,
|
||||
h,
|
||||
compute * 1000.0,
|
||||
full * 1000.0,
|
||||
1.0 / full
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Render a RAW file through the full pipeline and write a PPM.
|
||||
//!
|
||||
//! The end-to-end check: decode → demosaic → adjust → display encode, on a
|
||||
//! real file rather than a synthetic fixture. Unit tests prove each stage in
|
||||
//! isolation; this proves they compose into an image a person would accept.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p dr-gpu --example develop -- IMG.CR2 out.ppm
|
||||
//! ```
|
||||
//!
|
||||
//! PPM because it needs no encoder dependency and every image viewer reads
|
||||
//! it. This is a diagnostic, not the export path (FR-EXP-*).
|
||||
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_pipeline::ops::{
|
||||
blacks_whites, brilliance, colour_mixer, contrast, curve, exposure, highlights_shadows,
|
||||
vibrance, white_balance,
|
||||
};
|
||||
use dr_pipeline::{EditGraph, ParamId};
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
let Some(input) = args.next() else {
|
||||
eprintln!("usage: develop <file.cr2> [out.ppm] [preset]");
|
||||
eprintln!(" preset: neutral (default) | punchy | recover");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let output = args.next().unwrap_or_else(|| "develop.ppm".into());
|
||||
let preset = args.next().unwrap_or_else(|| "neutral".into());
|
||||
|
||||
let bytes = std::fs::read(&input).expect("read file");
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let raw = dr_decode::decode(&bytes).expect("decode");
|
||||
let decode_ms = t0.elapsed().as_secs_f32() * 1000.0;
|
||||
println!(
|
||||
"decoded {} × {} ({:?}), {decode_ms:.0} ms",
|
||||
raw.crop.width, raw.crop.height, raw.cfa_pattern
|
||||
);
|
||||
|
||||
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
|
||||
println!("adapter {} ({:?})", ctx.adapter_name(), ctx.backend());
|
||||
|
||||
let t1 = std::time::Instant::now();
|
||||
let demosaicer = Demosaicer::new(&ctx).expect("demosaicer");
|
||||
let image = demosaicer.run(&raw).expect("demosaic");
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
println!("demosaiced {:.0} ms", t1.elapsed().as_secs_f32() * 1000.0);
|
||||
|
||||
// Build an edit. The presets exist so the output can be eyeballed for
|
||||
// each operation actually doing something, not merely compiling.
|
||||
let mut graph = EditGraph::default_chain();
|
||||
match preset.as_str() {
|
||||
"punchy" => {
|
||||
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.3);
|
||||
graph.set_param(
|
||||
highlights_shadows::ID,
|
||||
highlights_shadows::HIGHLIGHTS,
|
||||
-40.0,
|
||||
);
|
||||
graph.set_param(highlights_shadows::ID, highlights_shadows::SHADOWS, 30.0);
|
||||
graph.set_param(blacks_whites::ID, blacks_whites::BLACKS, -20.0);
|
||||
graph.set_param(blacks_whites::ID, blacks_whites::WHITES, 25.0);
|
||||
graph.set_param(vibrance::ID, vibrance::VIBRANCE, 35.0);
|
||||
}
|
||||
"recover" => {
|
||||
graph.set_param(exposure::ID, exposure::EXPOSURE, -0.5);
|
||||
graph.set_param(
|
||||
highlights_shadows::ID,
|
||||
highlights_shadows::HIGHLIGHTS,
|
||||
-80.0,
|
||||
);
|
||||
graph.set_param(highlights_shadows::ID, highlights_shadows::SHADOWS, 60.0);
|
||||
graph.set_param(brilliance::ID, brilliance::BRILLIANCE, 40.0);
|
||||
graph.set_param(white_balance::ID, white_balance::TEMPERATURE, 15.0);
|
||||
}
|
||||
// Contrast alone, so its effect can be judged without anything else
|
||||
// moving.
|
||||
"contrast" => {
|
||||
graph.set_param(contrast::ID, contrast::CONTRAST, 60.0);
|
||||
}
|
||||
"flat" => {
|
||||
graph.set_param(contrast::ID, contrast::CONTRAST, -60.0);
|
||||
}
|
||||
// The mixer, pushed hard on the two things this scene actually has:
|
||||
// green vegetation and grey-blue rock.
|
||||
"mixer" => {
|
||||
graph.set_param(colour_mixer::ID, ParamId("green_sat"), 80.0);
|
||||
graph.set_param(colour_mixer::ID, ParamId("green_hue"), -40.0);
|
||||
graph.set_param(colour_mixer::ID, ParamId("chartreuse_sat"), 60.0);
|
||||
graph.set_param(colour_mixer::ID, ParamId("azure_lum"), -50.0);
|
||||
}
|
||||
// One band only, to check the weighting really is selective rather
|
||||
// than affecting the whole image.
|
||||
"mixer_one" => {
|
||||
graph.set_param(colour_mixer::ID, ParamId("green_sat"), 100.0);
|
||||
}
|
||||
// A classic S-curve: shadows down, highlights up, mid held.
|
||||
"curve_s" => {
|
||||
graph.set_param(curve::ID, curve::P1_Y, 0.15);
|
||||
graph.set_param(curve::ID, curve::P3_Y, 0.85);
|
||||
}
|
||||
// The inverse, a film-like lifted-shadow look.
|
||||
"curve_lift" => {
|
||||
graph.set_param(curve::ID, curve::P0_Y, 0.12);
|
||||
graph.set_param(curve::ID, curve::P1_Y, 0.32);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let shader = graph.compose();
|
||||
println!(
|
||||
"shader {} active op(s), {} uniform floats, structure {:016x}",
|
||||
shader.source.matches("---- ").count(),
|
||||
shader.uniforms.len(),
|
||||
shader.structure_hash
|
||||
);
|
||||
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
let (w, h) = image.size();
|
||||
|
||||
let t2 = std::time::Instant::now();
|
||||
adjust.render(&image, &shader, w, h).expect("adjust");
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
println!("adjusted {:.2} ms", t2.elapsed().as_secs_f32() * 1000.0);
|
||||
|
||||
// Time a second render with only a value changed: this is the slider
|
||||
// path, and it must not recompile.
|
||||
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.31);
|
||||
let again = graph.compose();
|
||||
let t3 = std::time::Instant::now();
|
||||
adjust.render(&image, &again, w, h).expect("adjust");
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
println!(
|
||||
"re-render {:.2} ms ({} pipeline(s) compiled)",
|
||||
t3.elapsed().as_secs_f32() * 1000.0,
|
||||
adjust.cached_pipelines()
|
||||
);
|
||||
|
||||
// `export_pixels`, because that is honestly what this is: the frame is
|
||||
// going into a PPM, not onto a screen. See the note on that method for
|
||||
// why the two readbacks were never the same thing (AC-8).
|
||||
let (pixels, pw, ph) = adjust.export_pixels().expect("readback");
|
||||
|
||||
// Sanity: an all-black or all-white result means something upstream
|
||||
// failed silently, and it is far easier to see here than in a viewer.
|
||||
let mut sum = 0u64;
|
||||
let mut min = 255u8;
|
||||
let mut max = 0u8;
|
||||
for px in pixels.chunks_exact(4) {
|
||||
let l = px[0].max(px[1]).max(px[2]);
|
||||
sum += u64::from(l);
|
||||
min = min.min(l);
|
||||
max = max.max(l);
|
||||
}
|
||||
let mean = sum as f64 / (pixels.len() / 4) as f64;
|
||||
println!("levels min {min}, mean {mean:.1}, max {max}");
|
||||
if max == 0 {
|
||||
eprintln!("WARNING: the image is entirely black");
|
||||
}
|
||||
|
||||
write_ppm(&output, &pixels, pw, ph);
|
||||
println!("wrote {output} ({pw} × {ph})");
|
||||
}
|
||||
|
||||
/// Write binary PPM (P6): a three-line header then RGB triples.
|
||||
fn write_ppm(path: &str, rgba: &[u8], w: u32, h: u32) {
|
||||
use std::io::Write;
|
||||
|
||||
let mut out = Vec::with_capacity((w * h * 3) as usize + 32);
|
||||
out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes());
|
||||
for px in rgba.chunks_exact(4) {
|
||||
out.extend_from_slice(&px[..3]);
|
||||
}
|
||||
std::fs::File::create(path)
|
||||
.expect("create output")
|
||||
.write_all(&out)
|
||||
.expect("write output");
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Local adjustments end to end, on a real photograph.
|
||||
//!
|
||||
//! Two edits a photographer actually makes, both driven by the model finding
|
||||
//! the subject rather than by anyone drawing a shape:
|
||||
//!
|
||||
//! - **The subject in colour, everything else monochrome.** One layer, the
|
||||
//! subject's mask inverted, saturation at −100.
|
||||
//! - **The subject lifted out of its background.** Two layers over the same
|
||||
//! mask: the subject brightened, the background pulled down.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p dr-gpu --example local --release \
|
||||
//! --features segment-readback -- photo.CR2 out
|
||||
//! ```
|
||||
//!
|
||||
//! Writes `<prefix>-original.ppm`, `<prefix>-colour-pop.ppm`,
|
||||
//! `<prefix>-subject-lift.ppm` and `<prefix>-mask.ppm`. PPM for the reason
|
||||
//! every other example here uses it: no encoder dependency, and every viewer
|
||||
//! reads it.
|
||||
//!
|
||||
//! # What this is really testing
|
||||
//!
|
||||
//! That the whole chain agrees with itself. The mask is rasterised in *source*
|
||||
//! space at proxy resolution and sampled by the composed shader after the
|
||||
//! framing map, so a fault anywhere in that handoff — a transposed axis, a
|
||||
//! mask pinned to the viewport, a slice read from the wrong layer — shows up
|
||||
//! here as an adjustment in the wrong place, and nowhere else.
|
||||
|
||||
use dr_gpu::{
|
||||
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, SubjectMasks,
|
||||
};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::{ops, EditGraph, Framing};
|
||||
use dr_segment::{SemanticModel, SemanticOptions, Shaped};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
/// Longest edge the mask and the model work at.
|
||||
const PROXY: u32 = 1600;
|
||||
/// Longest edge of the written frames.
|
||||
const OUT: u32 = 1400;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
let Some(path) = args.next() else {
|
||||
eprintln!("usage: local <photo.CR2|photo.RAF> [out-prefix]");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let prefix = args.next().unwrap_or_else(|| "local".into());
|
||||
|
||||
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu context");
|
||||
println!("gpu {}", ctx.adapter_name());
|
||||
|
||||
// ---- the photograph ---------------------------------------------------
|
||||
let bytes = std::fs::read(&path).expect("read file");
|
||||
let raw = dr_decode::decode(&bytes).expect("decode");
|
||||
println!("source {} × {}", raw.crop.width, raw.crop.height);
|
||||
let source = Demosaicer::new(&ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&raw)
|
||||
.expect("demosaic");
|
||||
|
||||
// ---- what the model sees ----------------------------------------------
|
||||
//
|
||||
// The *unedited* image, so the detection does not shift when the edit
|
||||
// does. Through `export_pixels`, which is ungated: an export is not the
|
||||
// display round-trip AC-8 forbids, and neither is this.
|
||||
let (sw, sh) = source.size();
|
||||
let scale = (PROXY as f32 / sw.max(sh) as f32).min(1.0);
|
||||
let (pw, ph) = (
|
||||
((sw as f32 * scale) as u32).max(1),
|
||||
((sh as f32 * scale) as u32).max(1),
|
||||
);
|
||||
|
||||
let neutral = EditGraph::default_chain();
|
||||
let mut proxy_pass = AdjustPass::new(&ctx);
|
||||
proxy_pass
|
||||
.render(&source, &neutral.compose(), pw, ph)
|
||||
.expect("proxy render");
|
||||
let (rgba, pw, ph) = proxy_pass.export_pixels().expect("proxy readback");
|
||||
println!("proxy {pw} × {ph}");
|
||||
|
||||
let rgb: Vec<f32> = rgba
|
||||
.chunks_exact(4)
|
||||
.flat_map(|p| {
|
||||
[
|
||||
p[0] as f32 / 255.0,
|
||||
p[1] as f32 / 255.0,
|
||||
p[2] as f32 / 255.0,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
|
||||
// ---- find the subject -------------------------------------------------
|
||||
let t = std::time::Instant::now();
|
||||
let mut model = SemanticModel::embedded().expect("model");
|
||||
let instances = model
|
||||
.detect(&rgb, pw as usize, ph as usize, &SemanticOptions::default())
|
||||
.expect("detect");
|
||||
println!(
|
||||
"detect {} found in {:.0} ms",
|
||||
instances.len(),
|
||||
t.elapsed().as_secs_f32() * 1000.0
|
||||
);
|
||||
for (i, inst) in instances.iter().enumerate() {
|
||||
println!(" [{i}] {:<14} {:.2}", inst.class_name, inst.score);
|
||||
}
|
||||
|
||||
let Some((index, subject)) = pick_subject(&instances) else {
|
||||
eprintln!("\nNothing recognised in this frame — nothing to adjust locally.");
|
||||
eprintln!("The model knows COCO's 80 classes; a landscape with no person,");
|
||||
eprintln!("animal or vehicle in it has no subject for it to find.");
|
||||
std::process::exit(1);
|
||||
};
|
||||
println!(
|
||||
"subject [{index}] {} at {:.2}",
|
||||
subject.class_name, subject.score
|
||||
);
|
||||
|
||||
// Quantised exactly as the develop session does, so this example exercises
|
||||
// the shipping path rather than a shortcut around it.
|
||||
let alpha: Vec<u8> = subject
|
||||
.mask
|
||||
.iter()
|
||||
.map(|&v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
.collect();
|
||||
|
||||
let (ow, oh) = fit(sw, sh, OUT);
|
||||
let mut masks = MaskPass::new(&ctx).expect("mask pass");
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
|
||||
// ---- the original, for comparison -------------------------------------
|
||||
adjust
|
||||
.render(&source, &neutral.compose(), ow, oh)
|
||||
.expect("render");
|
||||
write(&format!("{prefix}-original.ppm"), &adjust);
|
||||
|
||||
// ---- 1. the subject in colour, the rest monochrome --------------------
|
||||
//
|
||||
// One layer, inverted. Inverting rather than making a second mask for the
|
||||
// background is the whole point of having one: there is exactly one
|
||||
// boundary, so there is exactly one thing to get right.
|
||||
let mut pop = MaskStack::new();
|
||||
let mut drain = subject_layer("m1", index, subject);
|
||||
drain.invert = true;
|
||||
drain.set_param("saturation", ParamId("saturation"), -100.0);
|
||||
// A touch of feather, or the colour stops dead on the model's outline and
|
||||
// the eye goes straight to the edge instead of to the subject.
|
||||
drain.feather = 0.02;
|
||||
pop.push(drain);
|
||||
|
||||
render_stack(&ctx, &source, &mut masks, &mut adjust, &pop, &alpha, pw, ph, ow, oh);
|
||||
write(&format!("{prefix}-colour-pop.ppm"), &adjust);
|
||||
|
||||
// ---- 2. lift the subject out of its background ------------------------
|
||||
let mut lift = MaskStack::new();
|
||||
|
||||
let mut brighter = subject_layer("m1", index, subject);
|
||||
brighter.set_param("exposure", ParamId("exposure"), 0.45);
|
||||
brighter.feather = 0.015;
|
||||
lift.push(brighter);
|
||||
|
||||
let mut darker = subject_layer("m2", index, subject);
|
||||
darker.invert = true;
|
||||
darker.set_param("exposure", ParamId("exposure"), -0.55);
|
||||
darker.set_param("saturation", ParamId("saturation"), -25.0);
|
||||
darker.feather = 0.03;
|
||||
lift.push(darker);
|
||||
|
||||
render_stack(&ctx, &source, &mut masks, &mut adjust, &lift, &alpha, pw, ph, ow, oh);
|
||||
write(&format!("{prefix}-subject-lift.ppm"), &adjust);
|
||||
|
||||
// ---- 3. the same edit, grown and shrunk -------------------------------
|
||||
//
|
||||
// The model's outline is approximately right and slightly soft, so the
|
||||
// everyday correction is to move it: grow to catch a halo the detector
|
||||
// stopped short of, shrink to pull off one it caught. Both are a threshold
|
||||
// of the distance field, which is why they cost a uniform.
|
||||
for (name, morphology, radius) in [
|
||||
("grown", Morphology::Dilate, 0.012),
|
||||
("shrunk", Morphology::Erode, 0.012),
|
||||
] {
|
||||
let mut stack = MaskStack::new();
|
||||
let mut layer = subject_layer("m1", index, subject);
|
||||
layer.invert = true;
|
||||
layer.set_param("saturation", ParamId("saturation"), -100.0);
|
||||
layer.feather = 0.004;
|
||||
layer.morphology = morphology;
|
||||
layer.morph_radius = radius;
|
||||
stack.push(layer);
|
||||
|
||||
render_stack(&ctx, &source, &mut masks, &mut adjust, &stack, &alpha, pw, ph, ow, oh);
|
||||
write(&format!("{prefix}-{name}.ppm"), &adjust);
|
||||
}
|
||||
|
||||
// ---- the mask itself, to check the outline ----------------------------
|
||||
write_mask(&format!("{prefix}-mask.ppm"), &alpha, pw, ph);
|
||||
|
||||
println!("\nwrote {prefix}-original.ppm");
|
||||
println!(" {prefix}-colour-pop.ppm");
|
||||
println!(" {prefix}-subject-lift.ppm");
|
||||
println!(" {prefix}-grown.ppm, {prefix}-shrunk.ppm");
|
||||
println!(" {prefix}-mask.ppm");
|
||||
}
|
||||
|
||||
/// A layer masked to one detected object.
|
||||
fn subject_layer(id: &str, index: usize, subject: &dr_segment::Instance) -> MaskLayer {
|
||||
let mut layer = MaskLayer::new(
|
||||
id,
|
||||
MaskSource::Subject {
|
||||
// One segmentation in this process, so any signature agrees with
|
||||
// itself; the session computes a real one.
|
||||
signature: 0,
|
||||
index: index as u32,
|
||||
class: subject.class_name.to_string(),
|
||||
score: subject.score,
|
||||
},
|
||||
);
|
||||
layer.name = subject.class_name.to_string();
|
||||
layer
|
||||
}
|
||||
|
||||
/// The most promising thing to adjust.
|
||||
///
|
||||
/// Prefers a person, then falls back to the strongest detection of anything.
|
||||
/// Not because people are special to the pipeline, but because they are what a
|
||||
/// local adjustment is usually *for*, and an example that picks the parked car
|
||||
/// behind the subject demonstrates the mechanism while missing the point.
|
||||
fn pick_subject(instances: &[dr_segment::Instance]) -> Option<(usize, &dr_segment::Instance)> {
|
||||
instances
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, i)| &*i.class_name == "person")
|
||||
.or_else(|| instances.iter().enumerate().next())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_stack(
|
||||
ctx: &GpuContext,
|
||||
source: &DemosaicedImage,
|
||||
masks: &mut MaskPass,
|
||||
adjust: &mut AdjustPass,
|
||||
stack: &MaskStack,
|
||||
coverage: &[u8],
|
||||
pw: u32,
|
||||
ph: u32,
|
||||
ow: u32,
|
||||
oh: u32,
|
||||
) {
|
||||
// One signed distance field per active layer, in that order — the order
|
||||
// the rasteriser indexes them by. Built here rather than once up front
|
||||
// because a compound morphology rebuilds the field, so it belongs to the
|
||||
// layer that shaped it rather than to the object.
|
||||
let fields: Vec<Vec<f32>> = stack
|
||||
.active()
|
||||
.map(|layer| {
|
||||
Shaped::build(
|
||||
coverage,
|
||||
pw as usize,
|
||||
ph as usize,
|
||||
128,
|
||||
match layer.morphology {
|
||||
Morphology::None => dr_segment::Morphology::None,
|
||||
Morphology::Dilate => dr_segment::Morphology::Dilate,
|
||||
Morphology::Erode => dr_segment::Morphology::Erode,
|
||||
Morphology::Close => dr_segment::Morphology::Close,
|
||||
Morphology::Open => dr_segment::Morphology::Open,
|
||||
},
|
||||
layer.morph_radius * pw.min(ph) as f32,
|
||||
)
|
||||
.distance
|
||||
})
|
||||
.collect();
|
||||
let refs: Vec<&[f32]> = fields.iter().map(|f| f.as_slice()).collect();
|
||||
let subjects = SubjectMasks::upload(ctx, &refs, pw, ph).expect("upload fields");
|
||||
|
||||
// Rasterised at *proxy* size in source space, then sampled by the shader
|
||||
// after the framing map — which is what makes one mask correct at every
|
||||
// output size, zoom and crop.
|
||||
let array = masks
|
||||
.render(stack, None, Some(&subjects), pw, ph)
|
||||
.expect("rasterise masks");
|
||||
|
||||
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
|
||||
adjust
|
||||
.render_masked(source, &shader, ow, oh, Some(array))
|
||||
.expect("render");
|
||||
}
|
||||
|
||||
fn fit(w: u32, h: u32, longest: u32) -> (u32, u32) {
|
||||
let s = (longest as f32 / w.max(h) as f32).min(1.0);
|
||||
(((w as f32 * s) as u32).max(1), ((h as f32 * s) as u32).max(1))
|
||||
}
|
||||
|
||||
fn write(path: &str, adjust: &AdjustPass) {
|
||||
let (rgba, w, h) = adjust.export_pixels().expect("readback");
|
||||
let rgb: Vec<u8> = rgba.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
write_ppm(path, &rgb, w, h);
|
||||
}
|
||||
|
||||
fn write_mask(path: &str, alpha: &[u8], w: u32, h: u32) {
|
||||
let rgb: Vec<u8> = alpha.iter().flat_map(|&a| [a, a, a]).collect();
|
||||
write_ppm(path, &rgb, w, h);
|
||||
}
|
||||
|
||||
fn write_ppm(path: &str, rgb: &[u8], w: u32, h: u32) {
|
||||
use std::io::Write as _;
|
||||
let mut f = std::io::BufWriter::new(std::fs::File::create(path).expect("create"));
|
||||
write!(f, "P6\n{w} {h}\n255\n").expect("header");
|
||||
f.write_all(rgb).expect("body");
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Segment an image and write the granularity ladder as false-coloured PPMs.
|
||||
//!
|
||||
//! The whole point of S15 step 2 (docs/segmentation.md §11): look at the
|
||||
//! ladder and decide whether clicking through it would land on the things a
|
||||
//! person means. No amount of design settles that — the pictures do.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p dr-gpu --example segment --features readback -- IMG.CR2
|
||||
//! cargo run -p dr-gpu --example segment --features readback -- synthetic
|
||||
//! ```
|
||||
//!
|
||||
//! PPM for the same reason `develop` uses it: no encoder dependency, and
|
||||
//! every viewer reads it. This is a diagnostic, not an export path.
|
||||
|
||||
use dr_segment::{MergeTree, RegionField};
|
||||
use dr_gpu::{DemosaicedImage, Demosaicer, GpuContext, SegmentOptions, SegmentPass};
|
||||
|
||||
/// The ladder the example dumps. Chosen to span "far too fine to be useful"
|
||||
/// through "one or two objects", because both ends are informative: if no rung
|
||||
/// looks right, the gradient is wrong rather than the ladder being too coarse.
|
||||
const LEVELS: [usize; 6] = [2000, 800, 300, 120, 50, 16];
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
let Some(input) = args.next() else {
|
||||
eprintln!("usage: segment <file.raw|synthetic> [out-prefix] [blur-radius]");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let prefix = args.next().unwrap_or_else(|| "segment".into());
|
||||
let blur_radius = args
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(SegmentOptions::default().blur_radius);
|
||||
|
||||
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu context");
|
||||
println!("gpu {}", ctx.adapter_name());
|
||||
|
||||
let source = if input == "synthetic" {
|
||||
let (w, h) = (1200, 800);
|
||||
println!("source synthetic {w} × {h}");
|
||||
DemosaicedImage::from_rgba8(&ctx, &synthetic(w, h), w, h).expect("synthetic source")
|
||||
} else {
|
||||
let bytes = std::fs::read(&input).expect("read file");
|
||||
let raw = dr_decode::decode(&bytes).expect("decode");
|
||||
println!("source {} × {}", raw.crop.width, raw.crop.height);
|
||||
Demosaicer::new(&ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&raw)
|
||||
.expect("demosaic")
|
||||
};
|
||||
|
||||
let opts = SegmentOptions {
|
||||
blur_radius,
|
||||
..Default::default()
|
||||
};
|
||||
let pass = SegmentPass::new(&ctx).expect("segment pass");
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let seg = pass.run(&source, opts).expect("segment");
|
||||
let (w, h) = seg.size();
|
||||
let field = seg.read_field().expect("read field");
|
||||
let gpu_ms = t0.elapsed().as_secs_f32() * 1000.0;
|
||||
|
||||
let t1 = std::time::Instant::now();
|
||||
let tree = MergeTree::build(&field);
|
||||
let tree_ms = t1.elapsed().as_secs_f32() * 1000.0;
|
||||
|
||||
// M6, roughly: the readback is in `gpu_ms` and would not be there in a
|
||||
// shipping build, so this over-reports the GPU half rather than under.
|
||||
println!("proxy {w} × {h}, blur radius {blur_radius}");
|
||||
println!("basins {}", field.region_count);
|
||||
println!("boundaries {}", field.adjacency.len());
|
||||
println!("merges {}", tree.merges.len());
|
||||
println!("segment {gpu_ms:.0} ms (includes readback)");
|
||||
println!("hierarchy {tree_ms:.1} ms");
|
||||
|
||||
if let (Some(first), Some(last)) = (tree.merges.first(), tree.merges.last()) {
|
||||
println!("saddles {:.4} … {:.4}", first.saddle, last.saddle);
|
||||
}
|
||||
|
||||
for level in LEVELS {
|
||||
if level > field.region_count {
|
||||
println!("skip {level} (only {} basins)", field.region_count);
|
||||
continue;
|
||||
}
|
||||
let grouping = tree.cut_to(level);
|
||||
let pixels = field.apply(&grouping);
|
||||
let groups = grouping.iter().max().map(|m| m + 1).unwrap_or(0);
|
||||
let path = format!("{prefix}-{level:04}.ppm");
|
||||
write_ppm(&path, &false_colour(&pixels, &field), w, h);
|
||||
println!("wrote {path} ({groups} regions)");
|
||||
}
|
||||
|
||||
// The boundaries alone, which is what a snapped contour would cling to.
|
||||
let path = format!("{prefix}-edges.ppm");
|
||||
write_ppm(&path, &boundaries(&field), w, h);
|
||||
println!("wrote {path}");
|
||||
}
|
||||
|
||||
/// A distinct colour per region.
|
||||
///
|
||||
/// Hashed from the id rather than sampled from the image: two adjacent
|
||||
/// regions that happen to look alike are exactly the case worth seeing, and
|
||||
/// mean colours would hide it.
|
||||
fn false_colour(pixels: &[u32], field: &RegionField) -> Vec<u8> {
|
||||
let _ = field;
|
||||
let mut out = Vec::with_capacity(pixels.len() * 3);
|
||||
for &g in pixels {
|
||||
// Cheap integer hash — golden-ratio multiply, then spread the bits
|
||||
// across three channels.
|
||||
let mut x = g.wrapping_mul(2_654_435_761);
|
||||
x ^= x >> 15;
|
||||
out.push((x & 0xff) as u8);
|
||||
out.push(((x >> 8) & 0xff) as u8);
|
||||
out.push(((x >> 16) & 0xff) as u8);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// White where two regions meet, black elsewhere.
|
||||
fn boundaries(field: &RegionField) -> Vec<u8> {
|
||||
let (w, h) = (field.width, field.height);
|
||||
let mut out = vec![0u8; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
let edge = (x + 1 < w && field.labels[i] != field.labels[i + 1])
|
||||
|| (y + 1 < h && field.labels[i] != field.labels[i + w]);
|
||||
if edge {
|
||||
out[i * 3] = 255;
|
||||
out[i * 3 + 1] = 255;
|
||||
out[i * 3 + 2] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn write_ppm(path: &str, rgb: &[u8], width: u32, height: u32) {
|
||||
use std::io::Write as _;
|
||||
let mut f = std::io::BufWriter::new(std::fs::File::create(path).expect("create ppm"));
|
||||
write!(f, "P6\n{width} {height}\n255\n").expect("ppm header");
|
||||
f.write_all(rgb).expect("ppm body");
|
||||
}
|
||||
|
||||
/// A test image with the failure modes the corpus is meant to provoke, so the
|
||||
/// example is runnable before anyone has traced a single ground-truth mask.
|
||||
///
|
||||
/// Deliberately includes a soft gradient boundary and a noisy patch: those are
|
||||
/// where a watershed either earns its place or shatters, and a synthetic image
|
||||
/// of clean shapes would flatter it.
|
||||
fn synthetic(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut px = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let fx = x as f32 / w as f32;
|
||||
let fy = y as f32 / h as f32;
|
||||
|
||||
// A smooth vertical gradient — the low-contrast boundary case.
|
||||
let mut r = 40.0 + 120.0 * fy;
|
||||
let mut g = 60.0 + 100.0 * fy;
|
||||
let mut b = 110.0 + 90.0 * fy;
|
||||
|
||||
// A hard-edged disc: the control case.
|
||||
let d = ((fx - 0.3).powi(2) + (fy - 0.45).powi(2)).sqrt();
|
||||
if d < 0.16 {
|
||||
r = 210.0;
|
||||
g = 90.0;
|
||||
b = 60.0;
|
||||
}
|
||||
|
||||
// A soft-edged disc: where the ladder should merge late.
|
||||
let d2 = ((fx - 0.68).powi(2) + (fy - 0.6).powi(2)).sqrt();
|
||||
let t = (1.0 - (d2 / 0.18)).clamp(0.0, 1.0);
|
||||
r = r * (1.0 - t) + 90.0 * t;
|
||||
g = g * (1.0 - t) + 170.0 * t;
|
||||
b = b * (1.0 - t) + 110.0 * t;
|
||||
|
||||
// A noisy corner: the case pre-smoothing exists for.
|
||||
if fx > 0.82 && fy < 0.22 {
|
||||
let n = ((x * 7919 + y * 104_729) % 97) as f32 / 97.0;
|
||||
r += (n - 0.5) * 90.0;
|
||||
g += (n - 0.5) * 90.0;
|
||||
b += (n - 0.5) * 90.0;
|
||||
}
|
||||
|
||||
px.push(r.clamp(0.0, 255.0) as u8);
|
||||
px.push(g.clamp(0.0, 255.0) as u8);
|
||||
px.push(b.clamp(0.0, 255.0) as u8);
|
||||
px.push(255);
|
||||
}
|
||||
}
|
||||
px
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
//! The detail stage — running `dr-pipeline`'s neighbourhood passes.
|
||||
//!
|
||||
//! Where [`crate::AdjustPass`] fuses every point operation into one dispatch,
|
||||
//! this runs the operations that cannot be fused because they read pixels they
|
||||
//! are not writing: sharpening, noise reduction, clarity, texture, dehaze,
|
||||
//! spot removal (FR-DEV-3, FR-DEV-8). `dr_pipeline::detail` decides *what* they
|
||||
//! are and generates their WGSL; this compiles it, finds it somewhere to
|
||||
//! write, and dispatches it.
|
||||
//!
|
||||
//! # Nothing round-trips
|
||||
//!
|
||||
//! Every intermediate here is a `wgpu::Texture` and none of them is ever
|
||||
//! mapped. The chain is `demosaiced -> fused -> f16 -> f16 -> ... -> rgba8`,
|
||||
//! all of it on the device, and the last write lands in the same texture the
|
||||
//! compositor was already being handed. ARCH §6.1 and FR-DEV-4 are satisfied
|
||||
//! by there being no code here that could violate them, which is the only
|
||||
//! guarantee worth having.
|
||||
//!
|
||||
//! # Following the mask pass rather than inventing a second pattern
|
||||
//!
|
||||
//! `mask.rs` established how multi-target work is done in this crate, and this
|
||||
//! copies it deliberately:
|
||||
//!
|
||||
//! - **One encoder for the whole chain.** The mask pass rasterises every layer
|
||||
//! into one command buffer and submits once; this does the same for every
|
||||
//! pass. Submission order is the only synchronisation either needs, because
|
||||
//! both write and then read through the same queue.
|
||||
//! - **Textures reallocated on size change, never per frame.** `ensure_array`
|
||||
//! there, [`Intermediates::ensure`] here. Steady-state rendering at one
|
||||
//! viewport size allocates nothing.
|
||||
//! - **An allocation counter that exists to be asserted on.** Reallocating per
|
||||
//! frame instead of per resize costs a great deal of bandwidth and shows up
|
||||
//! nowhere in the output, which is exactly the kind of regression that needs
|
||||
//! a test that can see it.
|
||||
//! - **Pipelines cached by structure hash**, as `AdjustPass` caches its own.
|
||||
//! Moving a slider re-uploads a uniform buffer; it does not recompile.
|
||||
//!
|
||||
//! # The ping-pong, and why there are at most three textures
|
||||
//!
|
||||
//! Slot 0 holds what the fused colour pass wrote. It is kept **across frames**,
|
||||
//! which is what makes [`dr_pipeline::Affects::Detail`] mean something: when
|
||||
//! only a detail parameter has moved, the colour key is unchanged, the fused
|
||||
//! dispatch is skipped, and dragging a sharpening slider costs the detail
|
||||
//! passes alone (FR-DEV-3d).
|
||||
//!
|
||||
//! The remaining passes alternate between slots 1 and 2, and the last one
|
||||
//! writes the display texture directly rather than an intermediate — so a
|
||||
//! chain of *N* passes costs *N* dispatches and not *N* + 1, and there is no
|
||||
//! resolve pass to pay for. That leaves the allocation at `1 + min(N-1, 2)`
|
||||
//! textures: one for a single-pass operation, two for a separable blur, three
|
||||
//! however long the chain gets after that.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use dr_pipeline::detail::{ComposedDetail, ComposedDetailPass};
|
||||
use wgpu::util::DeviceExt as _;
|
||||
|
||||
use crate::{GpuContext, GpuError};
|
||||
|
||||
/// The format every intermediate carries.
|
||||
///
|
||||
/// The same `Rgba16Float` the demosaicer produces and the same one ARCH §5.2
|
||||
/// names as the working precision (FR-DEV-2). It is not a free choice: the
|
||||
/// stage exists between the colour pass and the output transform precisely so
|
||||
/// that a kernel runs on linear values at full internal precision, and an
|
||||
/// 8-bit intermediate would quantise twice and convolve display-encoded
|
||||
/// numbers — which is how sharpening comes to band a clear sky.
|
||||
pub const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
/// One linear working texture.
|
||||
struct Slot {
|
||||
#[allow(dead_code)]
|
||||
texture: wgpu::Texture,
|
||||
view: wgpu::TextureView,
|
||||
}
|
||||
|
||||
/// The pool of linear intermediates, sized to the chain and the viewport.
|
||||
struct Intermediates {
|
||||
slots: Vec<Slot>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
allocations: usize,
|
||||
}
|
||||
|
||||
impl Intermediates {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
slots: Vec::new(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
allocations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure `count` textures of this size exist.
|
||||
///
|
||||
/// Grows but never shrinks within a size: an edit that briefly had a
|
||||
/// three-pass chain and then a one-pass one keeps the spare texture rather
|
||||
/// than freeing and reallocating it the next time the user turns the
|
||||
/// operation back on. A size change drops the lot, because none of them
|
||||
/// fits any more.
|
||||
fn ensure(&mut self, ctx: &GpuContext, count: usize, width: u32, height: u32) {
|
||||
if self.width != width || self.height != height {
|
||||
self.slots.clear();
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
}
|
||||
while self.slots.len() < count {
|
||||
let texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("detail-intermediate"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: INTERMEDIATE_FORMAT,
|
||||
// STORAGE_BINDING to be written by a compute pass and
|
||||
// TEXTURE_BINDING to be read by the next one. Nothing else:
|
||||
// no RENDER_ATTACHMENT, because unlike the adjust pass's
|
||||
// output these are never handed to a compositor, and no
|
||||
// COPY_SRC, because nothing reads them back — that is the
|
||||
// point (ARCH §6.1).
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
self.slots.push(Slot { texture, view });
|
||||
self.allocations += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the detail stage.
|
||||
///
|
||||
/// Owned by [`crate::AdjustPass`] rather than standing alone, because the two
|
||||
/// halves are one render: the fused pass writes slot 0, this reads it, and the
|
||||
/// last pass writes the adjust pass's own output texture. Splitting them into
|
||||
/// two objects with two lifetimes would mean a caller could hold a stale
|
||||
/// intermediate against a fresh colour result and never be told.
|
||||
pub(crate) struct DetailRunner {
|
||||
ctx: GpuContext,
|
||||
/// Layout for a pass writing another linear intermediate.
|
||||
to_linear: Layout,
|
||||
/// Layout for the last pass, which writes the display texture.
|
||||
to_output: Layout,
|
||||
/// Compiled pipelines by pass structure hash.
|
||||
cache: HashMap<u64, wgpu::ComputePipeline>,
|
||||
pool: Intermediates,
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
bind_group: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::PipelineLayout,
|
||||
}
|
||||
|
||||
impl DetailRunner {
|
||||
pub(crate) fn new(ctx: &GpuContext) -> Self {
|
||||
Self {
|
||||
ctx: ctx.clone(),
|
||||
to_linear: Layout::new(ctx, INTERMEDIATE_FORMAT, "detail-linear"),
|
||||
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
|
||||
cache: HashMap::new(),
|
||||
pool: Intermediates::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The view the fused colour pass should write, given a chain of `passes`.
|
||||
///
|
||||
/// Slot 0, always — it is the one that survives between frames so that a
|
||||
/// detail-only change can skip the colour dispatch entirely.
|
||||
pub(crate) fn colour_target(
|
||||
&mut self,
|
||||
passes: usize,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> &wgpu::TextureView {
|
||||
// One for the colour pass's result, then one per hand-off between
|
||||
// detail passes, capped at two because a ping-pong needs no more: the
|
||||
// last pass writes the display texture rather than an intermediate.
|
||||
let needed = 1 + passes.saturating_sub(1).min(2);
|
||||
self.pool.ensure(&self.ctx, needed, width, height);
|
||||
&self.pool.slots[0].view
|
||||
}
|
||||
|
||||
/// Encode every pass of `chain`, the last one writing `output`.
|
||||
///
|
||||
/// The caller must already have run the fused colour pass into
|
||||
/// [`Self::colour_target`] — or established that a previous frame's is
|
||||
/// still valid, which is the whole point of keeping slot 0.
|
||||
pub(crate) fn encode(
|
||||
&mut self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
chain: &ComposedDetail,
|
||||
output: &wgpu::TextureView,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<usize, GpuError> {
|
||||
for pass in &chain.passes {
|
||||
self.compile(pass)?;
|
||||
}
|
||||
|
||||
for (index, pass) in chain.passes.iter().enumerate() {
|
||||
// Read what the previous pass wrote; write the next slot, or the
|
||||
// display texture if this is the last one. `index % 2` alternates
|
||||
// between slots 1 and 2, so a pass never reads the texture it is
|
||||
// writing — which on a compute pass is not an error the driver
|
||||
// reports, merely a picture that depends on scheduling.
|
||||
let source_slot = if index == 0 { 0 } else { 2 - (index % 2) };
|
||||
let source = &self.pool.slots[source_slot].view;
|
||||
let destination = if pass.writes_output {
|
||||
output
|
||||
} else {
|
||||
&self.pool.slots[1 + (index % 2)].view
|
||||
};
|
||||
let layout = if pass.writes_output {
|
||||
&self.to_output
|
||||
} else {
|
||||
&self.to_linear
|
||||
};
|
||||
|
||||
let params = self
|
||||
.ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("detail-params"),
|
||||
contents: bytemuck::cast_slice(&pass.uniforms),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
|
||||
let bind_group = self
|
||||
.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("detail-bg"),
|
||||
layout: &layout.bind_group,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(destination),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let pipeline = self
|
||||
.cache
|
||||
.get(&pass.structure_hash)
|
||||
.expect("compiled above");
|
||||
|
||||
let mut compute = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some(pass.label.as_str()),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
compute.set_pipeline(pipeline);
|
||||
compute.set_bind_group(0, &bind_group, &[]);
|
||||
compute.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
|
||||
}
|
||||
|
||||
Ok(chain.passes.len())
|
||||
}
|
||||
|
||||
/// Compile one pass, or leave the cached pipeline in place.
|
||||
///
|
||||
/// A validation error here is a codegen bug rather than anything the user
|
||||
/// did, so it is caught in an error scope and returned with the generated
|
||||
/// source and the pass's label attached — a line number against code
|
||||
/// nobody wrote, from one of several passes, is otherwise close to
|
||||
/// unactionable.
|
||||
fn compile(&mut self, pass: &ComposedDetailPass) -> Result<(), GpuError> {
|
||||
if self.cache.contains_key(&pass.structure_hash) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let scope = self
|
||||
.ctx
|
||||
.device
|
||||
.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
|
||||
let module = self
|
||||
.ctx
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(pass.label.as_str()),
|
||||
source: wgpu::ShaderSource::Wgsl(pass.source.as_str().into()),
|
||||
});
|
||||
|
||||
let layout = if pass.writes_output {
|
||||
&self.to_output
|
||||
} else {
|
||||
&self.to_linear
|
||||
};
|
||||
|
||||
let pipeline = self
|
||||
.ctx
|
||||
.device
|
||||
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(pass.label.as_str()),
|
||||
layout: Some(&layout.pipeline),
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
|
||||
if let Some(err) = pollster::block_on(scope.pop()) {
|
||||
return Err(GpuError::ShaderCompilation(format!(
|
||||
"detail pass {}: {err}\n\n--- generated source ---\n{}",
|
||||
pass.label,
|
||||
crate::adjust::numbered(&pass.source)
|
||||
)));
|
||||
}
|
||||
|
||||
self.cache.insert(pass.structure_hash, pipeline);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How many distinct detail pipelines are compiled. For tests asserting
|
||||
/// that slider movement does not recompile.
|
||||
pub(crate) fn cached_pipelines(&self) -> usize {
|
||||
self.cache.len()
|
||||
}
|
||||
|
||||
/// How many intermediate textures have been allocated since this pass was
|
||||
/// created. For tests — see [`crate::MaskPass::allocations`] for the
|
||||
/// regression this shape of counter exists to catch.
|
||||
pub(crate) fn allocations(&self) -> usize {
|
||||
self.pool.allocations
|
||||
}
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
fn new(ctx: &GpuContext, format: wgpu::TextureFormat, label: &str) -> Self {
|
||||
let bind_group = ctx
|
||||
.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(label),
|
||||
entries: &[
|
||||
// The previous stage's result.
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::StorageTexture {
|
||||
access: wgpu::StorageTextureAccess::WriteOnly,
|
||||
format,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let pipeline = ctx
|
||||
.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(label),
|
||||
bind_group_layouts: &[Some(&bind_group)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
Self {
|
||||
bind_group,
|
||||
pipeline,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/// TRACES: NFR-R7 | NFR-R8
|
||||
/// Failures from the GPU layer.
|
||||
///
|
||||
/// `DeviceLost` is deliberately a distinct variant rather than folded into a
|
||||
/// generic error: it is an expected event on Android (ARCH §6.10), not an
|
||||
/// exceptional one, and callers recover from it by rebuilding the device and
|
||||
/// re-driving from the edit graph.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GpuError {
|
||||
#[error("no suitable GPU adapter found")]
|
||||
NoAdapter,
|
||||
|
||||
#[error("failed to request device: {0}")]
|
||||
DeviceRequest(String),
|
||||
|
||||
#[error("GPU device lost — recreate and re-render from the edit graph")]
|
||||
DeviceLost,
|
||||
|
||||
#[error("shader compilation failed: {0}")]
|
||||
ShaderCompilation(String),
|
||||
|
||||
#[error("readback failed: {0}")]
|
||||
Readback(String),
|
||||
|
||||
/// The CFA layout is one no demosaic here handles — in practice a file
|
||||
/// `dr-decode` could not identify the pattern of. Reported rather than
|
||||
/// approximated with the Bayer path, which would produce a maze of colour
|
||||
/// artefacts and look like a corrupt file.
|
||||
#[error("unsupported CFA pattern: {0}")]
|
||||
UnsupportedCfa(String),
|
||||
|
||||
#[error("image too large for this device: {0}")]
|
||||
TooLarge(String),
|
||||
|
||||
/// A mask input that cannot describe the image it claims to — a label
|
||||
/// field whose length disagrees with its own dimensions, most often.
|
||||
///
|
||||
/// Its own variant rather than a panic because the caller assembles this
|
||||
/// from a segmentation and a render size that are computed in different
|
||||
/// places, and a mismatch between them is a bug worth reporting with its
|
||||
/// numbers rather than an abort.
|
||||
#[error("invalid mask input: {0}")]
|
||||
InvalidMask(String),
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
//! TRACES: FR-DSP-7
|
||||
//! Counting the display frame, on the device that drew it.
|
||||
//!
|
||||
//! # Why a compute reduction and not a CPU pass over the readback
|
||||
//!
|
||||
//! There is, today, a whole frame already sitting in CPU memory every time the
|
||||
//! canvas updates — `AdjustPass::read_output`, the temporary bridge that spike
|
||||
//! S1 removes. Walking it to build a histogram would have been perhaps thirty
|
||||
//! lines and no shader at all, and it was the obvious thing to reach for.
|
||||
//!
|
||||
//! It was rejected for two reasons, in this order.
|
||||
//!
|
||||
//! FR-DSP-7 states the mechanism, not just the feature: "these derive from a
|
||||
//! GPU-side reduction into a small buffer. Per-frame CPU readback of image data
|
||||
//! is prohibited." A histogram built on the bridge would be correct today and
|
||||
//! *deleted* by S1 — it would be new code whose only foundation is the one
|
||||
//! thing the architecture is committed to removing, and the histogram would
|
||||
//! then be the reason the bridge could not go.
|
||||
//!
|
||||
//! And the cost does not scale the way the shortcut implies. Counting 2 MP on
|
||||
//! one CPU thread is several milliseconds of the settle frame; the reduction
|
||||
//! below is a fraction of one, and what crosses the bus is 4104 bytes
|
||||
//! regardless of the image. The simpler-looking option is simpler only while
|
||||
//! the frame happens to be lying there.
|
||||
//!
|
||||
//! # What it costs and when it runs
|
||||
//!
|
||||
//! One dispatch plus a 4 KB buffer copy and a mapping — a device sync point.
|
||||
//! FR-DSP-7 requires that this not extend the FR-DSP-3 frame budget, so the
|
||||
//! interface runs it on the *settled* frame only, never on the draft frames a
|
||||
//! drag produces. The histogram of an image being dragged past is not read
|
||||
//! anyway; the one that arrives when the slider stops is.
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::readback::await_mapping;
|
||||
use crate::{GpuContext, GpuError};
|
||||
|
||||
/// Levels per channel. 256, so a bin *is* an output code value and no
|
||||
/// re-bucketing stands between the count and what the display shows.
|
||||
pub const BINS: usize = 256;
|
||||
|
||||
/// Four channel histograms plus the two clip counters, as the shader lays them
|
||||
/// out. Kept next to the shader's own constants because the two must agree.
|
||||
const CHANNELS: usize = 4;
|
||||
const CLIPPED_HIGH: usize = CHANNELS * BINS;
|
||||
const CLIPPED_LOW: usize = CHANNELS * BINS + 1;
|
||||
const SLOTS: usize = CHANNELS * BINS + 2;
|
||||
|
||||
/// TRACES: FR-DSP-7
|
||||
/// A counted frame: how many pixels sit at each output level.
|
||||
///
|
||||
/// Counts, not proportions. Turning these into something drawable — folding
|
||||
/// 256 bins into the columns a 280px panel can show, choosing a peak to scale
|
||||
/// against — is presentation, and belongs to whoever is drawing (ARCH §4.3a).
|
||||
/// What this crate owes is the numbers.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Histogram {
|
||||
red: [u32; BINS],
|
||||
green: [u32; BINS],
|
||||
blue: [u32; BINS],
|
||||
luma: [u32; BINS],
|
||||
clipped_highlights: u32,
|
||||
clipped_shadows: u32,
|
||||
pixels: u32,
|
||||
}
|
||||
|
||||
impl Histogram {
|
||||
/// Counts per output level, darkest first.
|
||||
pub fn red(&self) -> &[u32; BINS] {
|
||||
&self.red
|
||||
}
|
||||
|
||||
pub fn green(&self) -> &[u32; BINS] {
|
||||
&self.green
|
||||
}
|
||||
|
||||
pub fn blue(&self) -> &[u32; BINS] {
|
||||
&self.blue
|
||||
}
|
||||
|
||||
/// Rec.709 luma of the encoded values — the axis a photographer reads
|
||||
/// exposure off. See the shader for why it is weighted in fixed point.
|
||||
pub fn luma(&self) -> &[u32; BINS] {
|
||||
&self.luma
|
||||
}
|
||||
|
||||
/// Pixels with **any** channel at 255, and with any channel at 0.
|
||||
///
|
||||
/// Any rather than all, because a single blown channel is detail that is
|
||||
/// already gone: a red that has hit the ceiling has no gradation left in it
|
||||
/// however much green and blue still hold.
|
||||
pub fn clipped_highlights(&self) -> u32 {
|
||||
self.clipped_highlights
|
||||
}
|
||||
|
||||
pub fn clipped_shadows(&self) -> u32 {
|
||||
self.clipped_shadows
|
||||
}
|
||||
|
||||
/// Pixels counted. The denominator for the two figures above.
|
||||
pub fn pixels(&self) -> u32 {
|
||||
self.pixels
|
||||
}
|
||||
|
||||
/// Rebuild from the flat slot array the shader writes.
|
||||
///
|
||||
/// `pixels` is summed from the red channel rather than taken from the image
|
||||
/// dimensions: every pixel lands in exactly one red bin, so the sum *is*
|
||||
/// the count, and deriving it that way makes a dropped or double-counted
|
||||
/// texel show up as a wrong denominator instead of hiding.
|
||||
fn from_slots(slots: &[u32]) -> Result<Self, GpuError> {
|
||||
if slots.len() < SLOTS {
|
||||
return Err(GpuError::Readback(format!(
|
||||
"histogram readback was {} slots, expected {SLOTS}",
|
||||
slots.len()
|
||||
)));
|
||||
}
|
||||
let channel = |i: usize| -> [u32; BINS] {
|
||||
let mut out = [0u32; BINS];
|
||||
out.copy_from_slice(&slots[i * BINS..(i + 1) * BINS]);
|
||||
out
|
||||
};
|
||||
let red = channel(0);
|
||||
Ok(Self {
|
||||
pixels: red.iter().sum(),
|
||||
red,
|
||||
green: channel(1),
|
||||
blue: channel(2),
|
||||
luma: channel(3),
|
||||
clipped_highlights: slots[CLIPPED_HIGH],
|
||||
clipped_shadows: slots[CLIPPED_LOW],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The dispatch's view of the frame. Padded to 16 bytes for std140.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Dims {
|
||||
width: u32,
|
||||
height: u32,
|
||||
pad_0: u32,
|
||||
pad_1: u32,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-7
|
||||
/// Counts a rendered frame into [`Histogram`].
|
||||
///
|
||||
/// Holds its buffers for the life of the session. They are a fixed 4104 bytes
|
||||
/// whatever the image size — the one property that makes this affordable — so
|
||||
/// there is nothing to reallocate when the viewport changes, unlike the display
|
||||
/// target beside it.
|
||||
pub struct HistogramPass {
|
||||
ctx: GpuContext,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
bind_group_layout: wgpu::BindGroupLayout,
|
||||
/// Where the shader accumulates. Cleared before each dispatch.
|
||||
bins: wgpu::Buffer,
|
||||
/// Mappable destination; a storage buffer cannot also be `MAP_READ`.
|
||||
staging: wgpu::Buffer,
|
||||
dims: wgpu::Buffer,
|
||||
}
|
||||
|
||||
impl HistogramPass {
|
||||
pub fn new(ctx: &GpuContext) -> Result<Self, GpuError> {
|
||||
// A validation error here is a bug in the shader beside this file, not
|
||||
// anything a user did — surfaced as a `Result` rather than left to
|
||||
// wgpu's default handler, which panics.
|
||||
let scope = ctx.device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
|
||||
let module = ctx
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("histogram"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/histogram.wgsl").into()),
|
||||
});
|
||||
|
||||
let bind_group_layout =
|
||||
ctx.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("histogram-bgl"),
|
||||
entries: &[
|
||||
// The rendered frame, sampled with `textureLoad` — the
|
||||
// same texture the compositor shows, so what is counted
|
||||
// is what is on screen.
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: false },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let layout = ctx
|
||||
.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("histogram-layout"),
|
||||
bind_group_layouts: &[Some(&bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
let pipeline = ctx
|
||||
.device
|
||||
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("histogram-pipeline"),
|
||||
layout: Some(&layout),
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
|
||||
if let Some(err) = pollster::block_on(scope.pop()) {
|
||||
return Err(GpuError::ShaderCompilation(err.to_string()));
|
||||
}
|
||||
|
||||
let bytes = (SLOTS * std::mem::size_of::<u32>()) as u64;
|
||||
let bins = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("histogram-bins"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_SRC
|
||||
| wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let staging = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("histogram-staging"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let dims = ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("histogram-dims"),
|
||||
contents: bytemuck::bytes_of(&Dims {
|
||||
width: 0,
|
||||
height: 0,
|
||||
pad_0: 0,
|
||||
pad_1: 0,
|
||||
}),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
ctx: ctx.clone(),
|
||||
pipeline,
|
||||
bind_group_layout,
|
||||
bins,
|
||||
staging,
|
||||
dims,
|
||||
})
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-7
|
||||
/// Count one rendered frame.
|
||||
///
|
||||
/// `texture` must carry `TEXTURE_BINDING`, which `AdjustPass`'s output
|
||||
/// does because the compositor samples it.
|
||||
pub fn compute(&self, texture: &wgpu::Texture) -> Result<Histogram, GpuError> {
|
||||
let (width, height) = (texture.width(), texture.height());
|
||||
self.ctx.queue.write_buffer(
|
||||
&self.dims,
|
||||
0,
|
||||
bytemuck::bytes_of(&Dims {
|
||||
width,
|
||||
height,
|
||||
pad_0: 0,
|
||||
pad_1: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
let view = texture.create_view(&Default::default());
|
||||
let bind_group = self
|
||||
.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("histogram-bg"),
|
||||
layout: &self.bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: self.bins.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.dims.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let mut enc = self
|
||||
.ctx
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("histogram-encoder"),
|
||||
});
|
||||
// The accumulator is reused between frames, so it carries the previous
|
||||
// frame's counts until this line. Forgetting it does not fail — it
|
||||
// quietly integrates every frame since the image opened, which looks
|
||||
// like a histogram that will not respond to the exposure slider.
|
||||
enc.clear_buffer(&self.bins, 0, None);
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("histogram-pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &bind_group, &[]);
|
||||
pass.dispatch_workgroups(width.div_ceil(16), height.div_ceil(16), 1);
|
||||
}
|
||||
enc.copy_buffer_to_buffer(&self.bins, 0, &self.staging, 0, self.staging.size());
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
|
||||
let slice = self.staging.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
await_mapping(&self.ctx, &rx)?;
|
||||
|
||||
let data = slice.get_mapped_range();
|
||||
let slots: Vec<u32> = bytemuck::cast_slice::<u8, u32>(&data).to_vec();
|
||||
drop(data);
|
||||
self.staging.unmap();
|
||||
|
||||
Histogram::from_slots(&slots)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload `rgba` as a texture the pass can read, the way `AdjustPass`
|
||||
/// hands its output over.
|
||||
fn texture(ctx: &GpuContext, rgba: &[u8], width: u32, height: u32) -> wgpu::Texture {
|
||||
let tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("histogram-test-source"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
ctx.queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &tex,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
rgba,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(width * 4),
|
||||
rows_per_image: Some(height),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
ctx.queue.submit(std::iter::empty());
|
||||
tex
|
||||
}
|
||||
|
||||
/// The reference the shader is checked against: the same bucketing, written
|
||||
/// the obvious way on the CPU.
|
||||
///
|
||||
/// Deliberately a *second* implementation rather than shared code. The bugs
|
||||
/// this is here to catch — a workgroup tile that is never merged, an edge
|
||||
/// tile counted twice, a luma weighting that carries past 255 — are all
|
||||
/// bugs a shared implementation would commit identically on both sides and
|
||||
/// so could not detect.
|
||||
fn expected(rgba: &[u8]) -> Histogram {
|
||||
let mut slots = vec![0u32; SLOTS];
|
||||
for px in rgba.chunks_exact(4) {
|
||||
let (r, g, b) = (px[0] as usize, px[1] as usize, px[2] as usize);
|
||||
let y = (54 * r + 183 * g + 19 * b) >> 8;
|
||||
slots[r] += 1;
|
||||
slots[BINS + g] += 1;
|
||||
slots[2 * BINS + b] += 1;
|
||||
slots[3 * BINS + y] += 1;
|
||||
if px[0] == 255 || px[1] == 255 || px[2] == 255 {
|
||||
slots[CLIPPED_HIGH] += 1;
|
||||
}
|
||||
if px[0] == 0 || px[1] == 0 || px[2] == 0 {
|
||||
slots[CLIPPED_LOW] += 1;
|
||||
}
|
||||
}
|
||||
Histogram::from_slots(&slots).expect("slot count")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_frame_puts_every_pixel_in_one_bin() {
|
||||
// The arithmetic at its most checkable: 64x64 pixels of one value must
|
||||
// produce exactly 4096 in exactly one bin and nothing anywhere else.
|
||||
// A tile that failed to merge, or merged twice, changes this number —
|
||||
// and a histogram that is merely "roughly right" is a histogram nobody
|
||||
// can set a black point from.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let pass = HistogramPass::new(&ctx).expect("pass");
|
||||
|
||||
let rgba: Vec<u8> = std::iter::repeat_n([90u8, 140, 200, 255], 64 * 64)
|
||||
.flatten()
|
||||
.collect();
|
||||
let tex = texture(&ctx, &rgba, 64, 64);
|
||||
let hist = pass.compute(&tex).expect("compute");
|
||||
|
||||
assert_eq!(hist.pixels(), 4096);
|
||||
assert_eq!(hist.red()[90], 4096);
|
||||
assert_eq!(hist.green()[140], 4096);
|
||||
assert_eq!(hist.blue()[200], 4096);
|
||||
assert_eq!(
|
||||
hist.red().iter().filter(|c| **c > 0).count(),
|
||||
1,
|
||||
"one value can only occupy one bin"
|
||||
);
|
||||
// (54*90 + 183*140 + 19*200) >> 8 = 34280 >> 8 = 133.
|
||||
assert_eq!(hist.luma()[133], 4096);
|
||||
assert_eq!(hist.clipped_highlights(), 0);
|
||||
assert_eq!(hist.clipped_shadows(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_level_is_reachable_and_lands_where_it_belongs() {
|
||||
// A ramp covering all 256 codes, four pixels each. This is the test
|
||||
// that would catch an off-by-one in the quantisation — a `floor` where
|
||||
// a rounding was needed shifts the whole ramp down one bin and leaves
|
||||
// 255 empty, which on a real photograph looks like nothing at all.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let pass = HistogramPass::new(&ctx).expect("pass");
|
||||
|
||||
let (w, h) = (256u32, 4u32);
|
||||
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
|
||||
for _ in 0..h {
|
||||
for x in 0..w {
|
||||
let v = x as u8;
|
||||
rgba.extend_from_slice(&[v, v, v, 255]);
|
||||
}
|
||||
}
|
||||
let tex = texture(&ctx, &rgba, w, h);
|
||||
let hist = pass.compute(&tex).expect("compute");
|
||||
|
||||
assert_eq!(hist.pixels(), w * h);
|
||||
for level in 0..BINS {
|
||||
assert_eq!(
|
||||
hist.red()[level],
|
||||
h,
|
||||
"level {level} should hold exactly {h} pixels"
|
||||
);
|
||||
// Neutral, so luma must land on the same bin as the channels do.
|
||||
assert_eq!(hist.luma()[level], h, "luma drifted at level {level}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shader_agrees_with_a_cpu_count_of_the_same_frame() {
|
||||
// The cross-check, on a frame with no structure for a wrong dispatch to
|
||||
// hide behind: a size that is not a multiple of the 16x16 workgroup, so
|
||||
// the edge tiles run off the image, and pseudo-random content so every
|
||||
// bin is occupied unevenly. Exact equality — the reduction is integer
|
||||
// throughout precisely so this can be an `assert_eq`, not a tolerance.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let pass = HistogramPass::new(&ctx).expect("pass");
|
||||
|
||||
let (w, h) = (101u32, 37u32);
|
||||
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
|
||||
let mut state = 0x2545_F491_4F6C_DD1Du64;
|
||||
for _ in 0..w * h {
|
||||
for _ in 0..3 {
|
||||
// xorshift64*, so the frame is identical on every machine and a
|
||||
// failure can be reproduced rather than merely observed.
|
||||
state ^= state >> 12;
|
||||
state ^= state << 25;
|
||||
state ^= state >> 27;
|
||||
rgba.push((state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 56) as u8);
|
||||
}
|
||||
rgba.push(255);
|
||||
}
|
||||
|
||||
let tex = texture(&ctx, &rgba, w, h);
|
||||
let hist = pass.compute(&tex).expect("compute");
|
||||
|
||||
assert_eq!(hist.pixels(), w * h, "an edge tile was dropped or doubled");
|
||||
assert_eq!(hist, expected(&rgba));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_is_counted_per_pixel_and_not_per_channel() {
|
||||
// The distinction the indicator rests on. A pixel with two channels at
|
||||
// the ceiling is *one* clipped pixel; counting channels would report
|
||||
// 200% of a frame clipped, and a percentage that can exceed 100 is a
|
||||
// readout nobody will trust again.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let pass = HistogramPass::new(&ctx).expect("pass");
|
||||
|
||||
let rgba: Vec<u8> = [
|
||||
// Two channels blown, one pixel clipped.
|
||||
[255u8, 255, 10, 255],
|
||||
// One channel blown — still clipped, which is the point of "any".
|
||||
[255, 10, 10, 255],
|
||||
// Clean.
|
||||
[10, 10, 10, 255],
|
||||
// Black in one channel only: a clipped shadow.
|
||||
[0, 10, 10, 255],
|
||||
]
|
||||
.concat();
|
||||
let tex = texture(&ctx, &rgba, 4, 1);
|
||||
let hist = pass.compute(&tex).expect("compute");
|
||||
|
||||
assert_eq!(hist.pixels(), 4);
|
||||
assert_eq!(hist.clipped_highlights(), 2);
|
||||
assert_eq!(hist.clipped_shadows(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_frame_replaces_the_first_rather_than_adding_to_it() {
|
||||
// The accumulator is reused, so a missing clear integrates every frame
|
||||
// since the session opened. The symptom is subtle and awful: the
|
||||
// histogram keeps its shape and simply stops responding to the sliders,
|
||||
// because each frame's contribution shrinks against the running total.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let pass = HistogramPass::new(&ctx).expect("pass");
|
||||
|
||||
let dark: Vec<u8> = std::iter::repeat_n([40u8, 40, 40, 255], 16 * 16)
|
||||
.flatten()
|
||||
.collect();
|
||||
let bright: Vec<u8> = std::iter::repeat_n([210u8, 210, 210, 255], 16 * 16)
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
let first = pass.compute(&texture(&ctx, &dark, 16, 16)).expect("first");
|
||||
assert_eq!(first.red()[40], 256);
|
||||
|
||||
let second = pass
|
||||
.compute(&texture(&ctx, &bright, 16, 16))
|
||||
.expect("second");
|
||||
assert_eq!(second.pixels(), 256, "the previous frame was still counted");
|
||||
assert_eq!(second.red()[40], 0);
|
||||
assert_eq!(second.red()[210], 256);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
//! GPU device and compute for DarkRoom.
|
||||
//!
|
||||
//! In v0.1 this exists to prove one thing: a compute shader can write a
|
||||
//! texture that reaches the screen without a CPU round-trip (ARCH §6.1). It
|
||||
//! holds no pipeline, no tiling, and no masks — those arrive in v0.2.
|
||||
//!
|
||||
//! Deliberately free of UI dependencies (ARCH §6.5a). The texture is handed
|
||||
//! out as a `wgpu::Texture`; who composites it is not this crate's concern.
|
||||
//!
|
||||
//! That independence is why [`GpuContext::new_shared`] hands back the raw
|
||||
//! instance and adapter rather than talking to a compositor itself: the
|
||||
//! compositor will only sample a texture that came from the device *it* draws
|
||||
//! with, so somebody has to make one device for both — but it does not have to
|
||||
//! be this crate, and this crate must not know who it is.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
mod adjust;
|
||||
mod demosaic;
|
||||
mod detail;
|
||||
mod error;
|
||||
mod histogram;
|
||||
mod mask;
|
||||
mod readback;
|
||||
mod segment;
|
||||
pub use adjust::AdjustPass;
|
||||
// The format the neighbourhood stage works in. Public because it is a promise
|
||||
// rather than an implementation detail: a detail pass is guaranteed linear,
|
||||
// unclipped, full internal precision (FR-DEV-2), and anyone reasoning about
|
||||
// VRAM at 24 MP needs to know what an intermediate costs.
|
||||
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
|
||||
pub use demosaic::{DemosaicedImage, Demosaicer};
|
||||
pub use error::GpuError;
|
||||
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
|
||||
// at all at a crate root shared with demosaic and segmentation.
|
||||
pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS};
|
||||
pub use mask::{LabelField, MaskArray, MaskPass, SubjectMasks};
|
||||
pub use segment::{SegmentOptions, SegmentPass, Segmentation};
|
||||
|
||||
/// Owns the wgpu device and queue.
|
||||
///
|
||||
/// One device is shared by the compute pipeline and the UI, which is what
|
||||
/// allows compositing with no interop layer. Cloning is cheap and shares the
|
||||
/// same underlying device.
|
||||
#[derive(Clone)]
|
||||
pub struct GpuContext {
|
||||
pub device: Arc<wgpu::Device>,
|
||||
pub queue: Arc<wgpu::Queue>,
|
||||
adapter_info: wgpu::AdapterInfo,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-1 | AC-8
|
||||
/// One device, opened so that a compositor can be made to share it.
|
||||
///
|
||||
/// The texture the adjust pass writes only reaches the screen without a copy
|
||||
/// if the compositor is drawing with the *same* `wgpu::Device` — two devices
|
||||
/// are two address spaces, and a texture from one is not a texture the other
|
||||
/// can sample. So the device cannot be an implementation detail of either
|
||||
/// side; it has to be made once and handed to both.
|
||||
///
|
||||
/// [`Self::ctx`] is what the compute passes want. The instance and adapter are
|
||||
/// what a compositor wants in order to adopt the same setup — Slint's
|
||||
/// `WGPUConfiguration::Manual` asks for all four pieces — and they are handed
|
||||
/// out raw rather than wrapped, because naming Slint here would put a UI
|
||||
/// dependency in the one crate that must not have one (ARCH §6.5a).
|
||||
pub struct SharedGpu {
|
||||
/// The context every compute pass in this crate runs on.
|
||||
pub ctx: GpuContext,
|
||||
/// The instance the compositor will create its window surface from.
|
||||
pub instance: wgpu::Instance,
|
||||
/// The adapter [`Self::ctx`]'s device came from.
|
||||
pub adapter: wgpu::Adapter,
|
||||
}
|
||||
|
||||
impl GpuContext {
|
||||
/// Create a headless context — no surface, no window.
|
||||
///
|
||||
/// Used by tests, by the examples, and by anything that only needs to
|
||||
/// compute. A context opened this way cannot be shared with a compositor:
|
||||
/// see [`Self::new_shared`] for that, and for why the difference matters.
|
||||
pub async fn new_headless() -> Result<Self, GpuError> {
|
||||
// GL is allowed alongside Vulkan here and nowhere else: a machine with
|
||||
// no Vulkan loader should still run the tests, and a headless context
|
||||
// never has to produce a window surface — which is precisely the thing
|
||||
// the GL backend cannot do from an instance opened without a display
|
||||
// handle.
|
||||
Self::open(wgpu::Backends::VULKAN | wgpu::Backends::GL)
|
||||
.await
|
||||
.map(|shared| shared.ctx)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-1 | AC-8
|
||||
/// Open a device intended to be shared with the compositor.
|
||||
///
|
||||
/// Vulkan only, unlike [`Self::new_headless`]. The caller will hand the
|
||||
/// instance to a compositor that has to create a *window surface* from it,
|
||||
/// and wgpu's GL backend reaches its display through EGL at instance
|
||||
/// creation — an instance opened without a display handle, which is the
|
||||
/// only kind available before a window exists, cannot then produce a GL
|
||||
/// surface. Vulkan takes the window handle at surface creation instead, so
|
||||
/// it is the only backend this order of operations permits.
|
||||
///
|
||||
/// A machine with no Vulkan therefore gets no shared device, and the
|
||||
/// caller is expected to carry on without the develop path rather than
|
||||
/// refuse to start.
|
||||
pub async fn new_shared() -> Result<SharedGpu, GpuError> {
|
||||
// Vulkan on both targets (D1), and here it is not merely the
|
||||
// preference — see above.
|
||||
Self::open(wgpu::Backends::VULKAN).await
|
||||
}
|
||||
|
||||
async fn open(backends: wgpu::Backends) -> Result<SharedGpu, GpuError> {
|
||||
// `new_without_display_handle` rather than a struct literal: the
|
||||
// descriptor carries a boxed display handle and so has no `Default`,
|
||||
// and there is no window yet to take one from in either case.
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
descriptor.backends = backends;
|
||||
let instance = wgpu::Instance::new(descriptor);
|
||||
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: None,
|
||||
force_fallback_adapter: false,
|
||||
})
|
||||
.await
|
||||
// A `Result` since wgpu 24, where it was an `Option`. The error
|
||||
// says which backends were tried, which is worth more than the
|
||||
// bare "no adapter" this used to report.
|
||||
.map_err(|_| GpuError::NoAdapter)?;
|
||||
|
||||
let adapter_info = adapter.get_info();
|
||||
log::info!(
|
||||
"gpu: {} ({:?}, {:?})",
|
||||
adapter_info.name,
|
||||
adapter_info.device_type,
|
||||
adapter_info.backend
|
||||
);
|
||||
|
||||
let (device, queue) = adapter
|
||||
.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("darkroom-device"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
// Defaults, not `downlevel_defaults`: storage textures
|
||||
// in compute shaders are required, and the downlevel tier
|
||||
// does not guarantee them. This is effectively our GPU
|
||||
// floor (NFR-COMPAT-1).
|
||||
//
|
||||
// `using_resolution` raises only the texture-dimension limits,
|
||||
// to whatever this adapter actually offers. That matters once
|
||||
// a compositor shares this device: the default ceiling is
|
||||
// 8192, and a swapchain image for a large or scaled display
|
||||
// can exceed it — a limit we chose for our own compute passes
|
||||
// would otherwise silently cap somebody else's window.
|
||||
required_limits: wgpu::Limits::default().using_resolution(adapter.limits()),
|
||||
memory_hints: wgpu::MemoryHints::Performance,
|
||||
// Nothing behind a feature flag wgpu itself calls unstable —
|
||||
// the pipeline is ordinary compute and storage textures.
|
||||
experimental_features: wgpu::ExperimentalFeatures::disabled(),
|
||||
// The API trace, absorbed into the descriptor in wgpu 25 from
|
||||
// the second argument this call used to take.
|
||||
trace: wgpu::Trace::Off,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| GpuError::DeviceRequest(e.to_string()))?;
|
||||
|
||||
Ok(SharedGpu {
|
||||
ctx: Self {
|
||||
device: Arc::new(device),
|
||||
queue: Arc::new(queue),
|
||||
adapter_info,
|
||||
},
|
||||
instance,
|
||||
adapter,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a context from a device and queue owned by someone else — the
|
||||
/// path used when Slint has already created them.
|
||||
pub fn from_parts(
|
||||
device: Arc<wgpu::Device>,
|
||||
queue: Arc<wgpu::Queue>,
|
||||
adapter_info: wgpu::AdapterInfo,
|
||||
) -> Self {
|
||||
Self {
|
||||
device,
|
||||
queue,
|
||||
adapter_info,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn adapter_name(&self) -> &str {
|
||||
&self.adapter_info.name
|
||||
}
|
||||
|
||||
pub fn backend(&self) -> wgpu::Backend {
|
||||
self.adapter_info.backend
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Params {
|
||||
width: u32,
|
||||
height: u32,
|
||||
phase: f32,
|
||||
_pad: f32,
|
||||
}
|
||||
|
||||
/// A compute pass writing into a storage texture.
|
||||
///
|
||||
/// Stands in for the develop pipeline in v0.1. What matters is the shape:
|
||||
/// compute writes a texture, the texture is handed to the compositor, and
|
||||
/// pixels never travel back through the CPU.
|
||||
/// TRACES: FR-DEV-4 | R4
|
||||
pub struct RenderTarget {
|
||||
ctx: GpuContext,
|
||||
texture: wgpu::Texture,
|
||||
view: wgpu::TextureView,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
bind_group_layout: wgpu::BindGroupLayout,
|
||||
bind_group: wgpu::BindGroup,
|
||||
params_buf: wgpu::Buffer,
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Reused staging buffer for the temporary readback path. Allocating one
|
||||
/// per frame is a significant cost at large window sizes.
|
||||
#[cfg(any(test, feature = "readback"))]
|
||||
readback_buf: std::cell::RefCell<Option<(wgpu::Buffer, u32)>>,
|
||||
}
|
||||
|
||||
impl RenderTarget {
|
||||
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
pub fn new(ctx: &GpuContext, width: u32, height: u32) -> Result<Self, GpuError> {
|
||||
let (width, height) = (width.max(1), height.max(1));
|
||||
|
||||
let shader = ctx
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("gradient"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/gradient.wgsl").into()),
|
||||
});
|
||||
|
||||
let bind_group_layout =
|
||||
ctx.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("render-target-bgl"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::StorageTexture {
|
||||
access: wgpu::StorageTextureAccess::WriteOnly,
|
||||
format: Self::FORMAT,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let layout = ctx
|
||||
.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("render-target-layout"),
|
||||
bind_group_layouts: &[Some(&bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
let pipeline = ctx
|
||||
.device
|
||||
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("gradient-pipeline"),
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let params_buf = ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("params"),
|
||||
contents: bytemuck::bytes_of(&Params {
|
||||
width,
|
||||
height,
|
||||
phase: 0.0,
|
||||
_pad: 0.0,
|
||||
}),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
|
||||
let (texture, view) = Self::create_texture(ctx, width, height);
|
||||
let bind_group = Self::create_bind_group(ctx, &bind_group_layout, &view, ¶ms_buf);
|
||||
|
||||
Ok(Self {
|
||||
ctx: ctx.clone(),
|
||||
texture,
|
||||
view,
|
||||
pipeline,
|
||||
bind_group_layout,
|
||||
bind_group,
|
||||
params_buf,
|
||||
width,
|
||||
height,
|
||||
#[cfg(any(test, feature = "readback"))]
|
||||
readback_buf: std::cell::RefCell::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_texture(
|
||||
ctx: &GpuContext,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> (wgpu::Texture, wgpu::TextureView) {
|
||||
let texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("render-target"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: Self::FORMAT,
|
||||
// STORAGE_BINDING to write from compute; TEXTURE_BINDING so the
|
||||
// compositor can sample it. COPY_SRC exists only for tests —
|
||||
// production never reads this back (ARCH §6.1).
|
||||
//
|
||||
// RENDER_ATTACHMENT is not something this pass ever uses. It is
|
||||
// there because Slint refuses to import a texture without it
|
||||
// (`TextureImportError::InvalidUsage`), the compositor having to
|
||||
// assume it may need to draw into what it was given. Declaring an
|
||||
// unused capability costs an allocation flag and buys the whole
|
||||
// zero-copy path, so it is a cheap price for AC-8.
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
(texture, view)
|
||||
}
|
||||
|
||||
fn create_bind_group(
|
||||
ctx: &GpuContext,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
view: &wgpu::TextureView,
|
||||
params: &wgpu::Buffer,
|
||||
) -> wgpu::BindGroup {
|
||||
ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("render-target-bg"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Resize, reallocating the texture. No-op when unchanged.
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
let (width, height) = (width.max(1), height.max(1));
|
||||
if width == self.width && height == self.height {
|
||||
return;
|
||||
}
|
||||
let (texture, view) = Self::create_texture(&self.ctx, width, height);
|
||||
self.bind_group =
|
||||
Self::create_bind_group(&self.ctx, &self.bind_group_layout, &view, &self.params_buf);
|
||||
self.texture = texture;
|
||||
self.view = view;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
#[cfg(any(test, feature = "readback"))]
|
||||
{
|
||||
// Size changed, so the staging buffer no longer fits.
|
||||
*self.readback_buf.borrow_mut() = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the compute pass. Results stay on the GPU.
|
||||
pub fn render(&self, phase: f32) {
|
||||
self.ctx.queue.write_buffer(
|
||||
&self.params_buf,
|
||||
0,
|
||||
bytemuck::bytes_of(&Params {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
phase,
|
||||
_pad: 0.0,
|
||||
}),
|
||||
);
|
||||
|
||||
let mut enc = self
|
||||
.ctx
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("render-encoder"),
|
||||
});
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("gradient-pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
// 8x8 workgroups, rounded up so edge pixels are covered.
|
||||
pass.dispatch_workgroups(self.width.div_ceil(8), self.height.div_ceil(8), 1);
|
||||
}
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
}
|
||||
|
||||
pub fn texture(&self) -> &wgpu::Texture {
|
||||
&self.texture
|
||||
}
|
||||
|
||||
pub fn view(&self) -> &wgpu::TextureView {
|
||||
&self.view
|
||||
}
|
||||
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
/// Read pixels back to the CPU.
|
||||
///
|
||||
/// **Tests only.** Production code must never call this — it is exactly
|
||||
/// the round-trip ARCH §6.1 forbids, and AC-8 asserts it does not happen.
|
||||
#[cfg(any(test, feature = "readback"))]
|
||||
pub async fn read_pixels(&self) -> Result<Vec<u8>, GpuError> {
|
||||
// Buffer rows must be aligned to COPY_BYTES_PER_ROW_ALIGNMENT (256).
|
||||
let unpadded = self.width * 4;
|
||||
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let padded = unpadded.div_ceil(align) * align;
|
||||
|
||||
let needed = (padded * self.height) as u64;
|
||||
let mut slot = self.readback_buf.borrow_mut();
|
||||
if slot.as_ref().map(|(_, p)| *p) != Some(padded) {
|
||||
*slot = Some((
|
||||
self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("readback"),
|
||||
size: needed,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
padded,
|
||||
));
|
||||
}
|
||||
let buf = &slot.as_ref().unwrap().0;
|
||||
|
||||
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
|
||||
enc.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &self.texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: buf,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(padded),
|
||||
rows_per_image: Some(self.height),
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
|
||||
let slice = buf.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
// Fallible since wgpu 26, and worth propagating rather than ignoring:
|
||||
// the failure it reports is a lost device (NFR-R7), and without this
|
||||
// the map callback below simply never arrives and the error surfaces
|
||||
// as a timeout somewhere less informative.
|
||||
self.ctx
|
||||
.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.map_err(|e| GpuError::Readback(e.to_string()))?;
|
||||
rx.recv()
|
||||
.map_err(|e| GpuError::Readback(e.to_string()))?
|
||||
.map_err(|e| GpuError::Readback(e.to_string()))?;
|
||||
|
||||
// Strip row padding.
|
||||
let data = slice.get_mapped_range();
|
||||
let mut out = Vec::with_capacity((unpadded * self.height) as usize);
|
||||
for row in 0..self.height {
|
||||
let start = (row * padded) as usize;
|
||||
out.extend_from_slice(&data[start..start + unpadded as usize]);
|
||||
}
|
||||
drop(data);
|
||||
buf.unmap();
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
// CI runners and headless machines may have no usable adapter. Skip
|
||||
// rather than fail — the device-dependent assertions still run
|
||||
// wherever a GPU exists.
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_writes_the_texture() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let rt = RenderTarget::new(&ctx, 64, 64).expect("render target");
|
||||
rt.render(0.0);
|
||||
|
||||
let px = pollster::block_on(rt.read_pixels()).expect("readback");
|
||||
assert_eq!(px.len(), 64 * 64 * 4);
|
||||
|
||||
// The shader writes opaque pixels everywhere; an all-zero buffer would
|
||||
// mean the dispatch silently did nothing.
|
||||
assert!(
|
||||
px.chunks_exact(4).all(|p| p[3] == 255),
|
||||
"every pixel should be opaque"
|
||||
);
|
||||
assert!(
|
||||
px.iter().any(|&b| b != 0),
|
||||
"texture should not be uniformly zero"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_changes_output() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let rt = RenderTarget::new(&ctx, 32, 32).expect("render target");
|
||||
|
||||
rt.render(0.0);
|
||||
let a = pollster::block_on(rt.read_pixels()).expect("readback");
|
||||
rt.render(std::f32::consts::PI);
|
||||
let b = pollster::block_on(rt.read_pixels()).expect("readback");
|
||||
|
||||
assert_ne!(a, b, "moving the highlight should change the image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_reallocates() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut rt = RenderTarget::new(&ctx, 16, 16).expect("render target");
|
||||
assert_eq!(rt.size(), (16, 16));
|
||||
|
||||
rt.resize(48, 24);
|
||||
assert_eq!(rt.size(), (48, 24));
|
||||
|
||||
rt.render(0.0);
|
||||
let px = pollster::block_on(rt.read_pixels()).expect("readback");
|
||||
assert_eq!(px.len(), 48 * 24 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_size_is_clamped() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
// A minimised window reports zero; texture creation would panic.
|
||||
let rt = RenderTarget::new(&ctx, 0, 0).expect("render target");
|
||||
assert_eq!(rt.size(), (1, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Waiting for a buffer mapping without parking the interface.
|
||||
//!
|
||||
//! Shared by every transfer off the device — the display bridge, an export, and
|
||||
//! the histogram's 4 KB of bin counts. It was written once inside `AdjustPass`
|
||||
//! and the reasoning below is the whole of why it is shaped this way; copying
|
||||
//! thirty lines of that reasoning into a second caller would have left two
|
||||
//! copies to keep true of each other.
|
||||
|
||||
use crate::{GpuContext, GpuError};
|
||||
|
||||
/// How many non-blocking polls a readback gets before it is called failed.
|
||||
///
|
||||
/// A bound rather than a spin forever: if the device is lost the map callback
|
||||
/// never arrives, and an unbounded loop would hang the interface rather than
|
||||
/// surfacing the error. Set far above any plausible completion — the copies
|
||||
/// this waits on are milliseconds — so it is reached only when something is
|
||||
/// wrong.
|
||||
/// How long a readback may take before it is called failed.
|
||||
///
|
||||
/// **A deadline, not an iteration count, and the difference was a real bug.**
|
||||
/// This was 100,000 non-blocking polls, which sounds generous and is not: a
|
||||
/// `Poll` that finds nothing returns immediately, so the loop burned through
|
||||
/// the whole budget in a few milliseconds. Small transfers — the histogram's
|
||||
/// 4 KB, a viewport-sized frame — happened to complete inside it. A
|
||||
/// full-resolution export did not: 5472×3648 is 80 MB, and every attempt
|
||||
/// failed with "readback did not complete" while the copy was still perfectly
|
||||
/// healthy.
|
||||
///
|
||||
/// Generous, because the legitimate worst case is a large export on a slow
|
||||
/// integrated GPU, and the only thing this bound exists to catch is a lost
|
||||
/// device that will never deliver the callback at all.
|
||||
const READBACK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// How long to wait between polls once the first few have found nothing.
|
||||
///
|
||||
/// Without it this is a busy spin that saturates a core for the duration of
|
||||
/// the copy. A millisecond is far below the transfer times involved and keeps
|
||||
/// the thread available to the scheduler.
|
||||
const POLL_PAUSE: std::time::Duration = std::time::Duration::from_millis(1);
|
||||
|
||||
/// Drive the device until a `map_async` callback lands.
|
||||
///
|
||||
/// **Polled without blocking, then checked.**
|
||||
///
|
||||
/// `PollType::Wait` parks the calling thread until the GPU has finished, and
|
||||
/// these transfers are called from the UI thread — so that park was a frozen
|
||||
/// interface for the duration of the copy (~7 ms at 4K for a whole frame).
|
||||
/// `Poll` drives the same callbacks without sleeping, so the loop below stays
|
||||
/// interruptible and the mapping still completes.
|
||||
///
|
||||
/// The bounded spin matters: a lost device would otherwise never deliver the
|
||||
/// callback and this would hang the app instead of reporting an error.
|
||||
pub(crate) fn await_mapping(
|
||||
ctx: &GpuContext,
|
||||
rx: &std::sync::mpsc::Receiver<Result<(), wgpu::BufferAsyncError>>,
|
||||
) -> Result<(), GpuError> {
|
||||
let mut mapped = None;
|
||||
let deadline = std::time::Instant::now() + READBACK_DEADLINE;
|
||||
let mut spins = 0u32;
|
||||
while std::time::Instant::now() < deadline {
|
||||
// A poll error is a lost device, which is exactly the case the bounded
|
||||
// spin exists to escape — returning here reports it immediately rather
|
||||
// than spinning out the full limit first.
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::Poll)
|
||||
.map_err(|e| GpuError::Readback(e.to_string()))?;
|
||||
match rx.try_recv() {
|
||||
Ok(r) => {
|
||||
mapped = Some(r);
|
||||
break;
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {
|
||||
// The first handful of polls run flat out, so a small
|
||||
// transfer — the histogram's, most of all — still returns
|
||||
// without ever sleeping. Only a copy that is genuinely going
|
||||
// to take a while pays the pause.
|
||||
spins += 1;
|
||||
if spins > 64 {
|
||||
std::thread::sleep(POLL_PAUSE);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(GpuError::Readback(e.to_string())),
|
||||
}
|
||||
}
|
||||
mapped
|
||||
.ok_or_else(|| {
|
||||
GpuError::Readback(format!(
|
||||
"readback did not complete within {}s",
|
||||
READBACK_DEADLINE.as_secs()
|
||||
))
|
||||
})?
|
||||
.map_err(|e| GpuError::Readback(e.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
//! Watershed segmentation — arm A's GPU half (S15, docs/segmentation.md).
|
||||
//!
|
||||
//! Runs the five passes in `shaders/watershed.wgsl` over a demosaiced image
|
||||
//! and leaves a basin label per pixel on the GPU. The hierarchy built from
|
||||
//! those labels lives in [`dr_segment`], which needs no device.
|
||||
//!
|
||||
//! # Cost
|
||||
//!
|
||||
//! Every pass is a trivial kernel and the whole chain is a handful of
|
||||
//! milliseconds at proxy resolution. It runs **once per image**, off the
|
||||
//! interactive path — the point of precomputing a region map is that
|
||||
//! selection afterwards is a label comparison rather than a flood fill.
|
||||
//!
|
||||
//! # The open question this leaves
|
||||
//!
|
||||
//! [`Segmentation::read_field`] copies the label and gradient buffers back to
|
||||
//! the CPU to build the region adjacency graph, behind the `segment-readback`
|
||||
//! feature. That is deliberately **not** the `readback` switch guarding the
|
||||
//! display round-trip: this transfer is once per image on a worker, where the
|
||||
//! one AC-8 forbids is per frame in the render loop, and sharing a switch
|
||||
//! would force a build wanting local masking to unlock the other.
|
||||
//!
|
||||
//! It is still a real cost and still unfinished. F3 in docs/segmentation.md
|
||||
//! §12 stands: the adjacency accumulation belongs GPU-side with atomics, and
|
||||
//! until it moves there every segmentation pays a full-resolution transfer.
|
||||
//! Read the feature name as a description of a known gap rather than as
|
||||
//! permission.
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::{DemosaicedImage, GpuContext, GpuError};
|
||||
|
||||
/// How the watershed is tuned for one image.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SegmentOptions {
|
||||
/// Longest proxy edge. The segmentation runs here, not at sensor
|
||||
/// resolution: a 24 MP watershed costs 12× the memory to place boundaries
|
||||
/// a person cannot see, and the boundary refinement that matters at 1:1
|
||||
/// is a separate stage (docs/segmentation.md §4).
|
||||
pub max_edge: u32,
|
||||
/// Pre-smoothing radius in proxy pixels. The caller's to raise with ISO —
|
||||
/// this is the single knob that decides whether a noisy file segments
|
||||
/// into regions or into grain.
|
||||
pub blur_radius: i32,
|
||||
pub w_luma: f32,
|
||||
pub w_chroma: f32,
|
||||
/// How far the lower-completion carries a distance inward from a
|
||||
/// plateau's rim, in breadth-first steps.
|
||||
///
|
||||
/// Bounds the widest plateau that resolves fully. Beyond it, the interior
|
||||
/// keeps the behaviour it had before the pass existed — a fan of diagonal
|
||||
/// chains — so this trades dispatches against the size of flat area the
|
||||
/// watershed handles cleanly, and never against correctness elsewhere.
|
||||
pub plateau_iterations: u32,
|
||||
}
|
||||
|
||||
impl Default for SegmentOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// ~1.3 MP at 3:2. Large enough that a boundary is within a pixel
|
||||
// or two of where it belongs, small enough that the whole chain
|
||||
// fits comfortably in memory on a phone.
|
||||
max_edge: 1600,
|
||||
blur_radius: 2,
|
||||
w_luma: 1.0,
|
||||
// Chroma carries most of the sensor noise and few of the
|
||||
// boundaries anyone would draw, so it counts for less — but not
|
||||
// zero, or a red flower on green leaves has no edge at all.
|
||||
w_chroma: 0.5,
|
||||
// **Zero: the pass is off.** It is implemented, dispatched
|
||||
// correctly and measurably changes nothing — see the ignored test
|
||||
// below and §12 of docs/segmentation.md. Until that is understood,
|
||||
// running it would buy 64 dispatches per segmentation and no
|
||||
// improvement, so the default declines to pay.
|
||||
plateau_iterations: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct SegParams {
|
||||
width: u32,
|
||||
height: u32,
|
||||
src_width: u32,
|
||||
src_height: u32,
|
||||
blur_radius: i32,
|
||||
non_linear: u32,
|
||||
w_luma: f32,
|
||||
w_chroma: f32,
|
||||
}
|
||||
|
||||
/// One compute stage: its layout and its compiled pipeline.
|
||||
struct Stage {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
}
|
||||
|
||||
/// Runs the watershed chain.
|
||||
pub struct SegmentPass {
|
||||
ctx: GpuContext,
|
||||
features: Stage,
|
||||
blur: Stage,
|
||||
gradient: Stage,
|
||||
plateau_init: Stage,
|
||||
plateau_step: Stage,
|
||||
flow: Stage,
|
||||
jump: Stage,
|
||||
}
|
||||
|
||||
impl SegmentPass {
|
||||
pub fn new(ctx: &GpuContext) -> Result<Self, GpuError> {
|
||||
// A validation failure here is a bug in the shader, not a user error.
|
||||
// Surfaced as a Result rather than wgpu's default panic, matching how
|
||||
// `AdjustPass` handles its generated source.
|
||||
let scope = ctx.device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
|
||||
let module = ctx
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("watershed"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/watershed.wgsl").into()),
|
||||
});
|
||||
|
||||
let features = {
|
||||
let layout = ctx
|
||||
.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("watershed-features-bgl"),
|
||||
entries: &[
|
||||
uniform_entry(0),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
storage_entry(2, false),
|
||||
],
|
||||
});
|
||||
let pipeline = compute(ctx, &module, &layout, "features");
|
||||
Stage { layout, pipeline }
|
||||
};
|
||||
|
||||
let buffer_stage = |in_binding: u32, out_binding: u32, entry: &str, label: &str| {
|
||||
let layout = ctx
|
||||
.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(label),
|
||||
entries: &[
|
||||
uniform_entry(0),
|
||||
storage_entry(in_binding, true),
|
||||
storage_entry(out_binding, false),
|
||||
],
|
||||
});
|
||||
let pipeline = compute(ctx, &module, &layout, entry);
|
||||
Stage { layout, pipeline }
|
||||
};
|
||||
|
||||
// Three bindings rather than two: these read the gradient *and* a
|
||||
// distance field, and write a second one.
|
||||
let triple_stage = |a: u32, b: u32, c: u32, entry: &str, label: &str| {
|
||||
let layout = ctx
|
||||
.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(label),
|
||||
entries: &[
|
||||
uniform_entry(0),
|
||||
storage_entry(a, true),
|
||||
storage_entry(b, true),
|
||||
storage_entry(c, false),
|
||||
],
|
||||
});
|
||||
let pipeline = compute(ctx, &module, &layout, entry);
|
||||
Stage { layout, pipeline }
|
||||
};
|
||||
|
||||
let blur = buffer_stage(3, 4, "blur", "watershed-blur-bgl");
|
||||
let gradient = buffer_stage(5, 6, "gradient", "watershed-gradient-bgl");
|
||||
let plateau_init = buffer_stage(7, 8, "plateau_init", "watershed-pinit-bgl");
|
||||
let plateau_step = triple_stage(9, 10, 11, "plateau_step", "watershed-pstep-bgl");
|
||||
let flow = triple_stage(12, 13, 14, "flow", "watershed-flow-bgl");
|
||||
let jump = buffer_stage(15, 16, "jump", "watershed-jump-bgl");
|
||||
|
||||
if let Some(err) = pollster::block_on(scope.pop()) {
|
||||
return Err(GpuError::ShaderCompilation(err.to_string()));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
ctx: ctx.clone(),
|
||||
features,
|
||||
blur,
|
||||
gradient,
|
||||
plateau_init,
|
||||
plateau_step,
|
||||
flow,
|
||||
jump,
|
||||
})
|
||||
}
|
||||
|
||||
/// Segment an image into basins.
|
||||
pub fn run(
|
||||
&self,
|
||||
source: &DemosaicedImage,
|
||||
opts: SegmentOptions,
|
||||
) -> Result<Segmentation, GpuError> {
|
||||
let (src_w, src_h) = source.size();
|
||||
let (width, height) = proxy_size(src_w, src_h, opts.max_edge);
|
||||
let n = (width * height) as u64;
|
||||
|
||||
let params = self
|
||||
.ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("watershed-params"),
|
||||
contents: bytemuck::bytes_of(&SegParams {
|
||||
width,
|
||||
height,
|
||||
src_width: src_w,
|
||||
src_height: src_h,
|
||||
blur_radius: opts.blur_radius,
|
||||
non_linear: u32::from(source.is_non_linear()),
|
||||
w_luma: opts.w_luma,
|
||||
w_chroma: opts.w_chroma,
|
||||
}),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
|
||||
// `vec4` rather than `vec3` for the feature buffers: a WGSL storage
|
||||
// array of vec3 still strides by 16 bytes, so packing to three floats
|
||||
// would save nothing and cost an index calculation.
|
||||
let feat_a = self.buffer("watershed-feat-a", n * 16, false);
|
||||
let feat_b = self.buffer("watershed-feat-b", n * 16, false);
|
||||
let gradient = self.buffer("watershed-gradient", n * 4, true);
|
||||
let dist_a = self.buffer("watershed-dist-a", n * 4, false);
|
||||
let dist_b = self.buffer("watershed-dist-b", n * 4, false);
|
||||
let parent_a = self.buffer("watershed-parent-a", n * 4, true);
|
||||
let parent_b = self.buffer("watershed-parent-b", n * 4, true);
|
||||
|
||||
let mut enc = self
|
||||
.ctx
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("watershed-encoder"),
|
||||
});
|
||||
|
||||
let groups = (width.div_ceil(8), height.div_ceil(8));
|
||||
|
||||
let features_bg = self
|
||||
.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("watershed-features-bg"),
|
||||
layout: &self.features.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(source.view()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: feat_a.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let blur_bg = self.bind(&self.blur.layout, ¶ms, 3, &feat_a, 4, &feat_b);
|
||||
let gradient_bg = self.bind(&self.gradient.layout, ¶ms, 5, &feat_b, 6, &gradient);
|
||||
let pinit_bg = self.bind(&self.plateau_init.layout, ¶ms, 7, &gradient, 8, &dist_a);
|
||||
let pstep_ab = self.bind3(
|
||||
&self.plateau_step.layout,
|
||||
¶ms,
|
||||
(9, &gradient),
|
||||
(10, &dist_a),
|
||||
(11, &dist_b),
|
||||
);
|
||||
let pstep_ba = self.bind3(
|
||||
&self.plateau_step.layout,
|
||||
¶ms,
|
||||
(9, &gradient),
|
||||
(10, &dist_b),
|
||||
(11, &dist_a),
|
||||
);
|
||||
|
||||
// An odd number of plateau steps leaves the distance field in B.
|
||||
let plateau_steps = opts.plateau_iterations;
|
||||
let final_dist = if plateau_steps.is_multiple_of(2) {
|
||||
&dist_a
|
||||
} else {
|
||||
&dist_b
|
||||
};
|
||||
|
||||
let flow_bg = self.bind3(
|
||||
&self.flow.layout,
|
||||
¶ms,
|
||||
(12, &gradient),
|
||||
(13, final_dist),
|
||||
(14, &parent_a),
|
||||
);
|
||||
let jump_ab = self.bind(&self.jump.layout, ¶ms, 15, &parent_a, 16, &parent_b);
|
||||
let jump_ba = self.bind(&self.jump.layout, ¶ms, 15, &parent_b, 16, &parent_a);
|
||||
|
||||
// Pointer jumping halves every path per pass, so log2 of the pixel
|
||||
// count bounds it — that is the longest possible descent chain. A
|
||||
// convergence test would cost a readback per iteration to save a
|
||||
// handful of dispatches of a two-line kernel.
|
||||
let jumps = (n as f64).log2().ceil() as u32 + 1;
|
||||
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("watershed-pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
|
||||
for (pipeline, bg) in [
|
||||
(&self.features.pipeline, &features_bg),
|
||||
(&self.blur.pipeline, &blur_bg),
|
||||
(&self.gradient.pipeline, &gradient_bg),
|
||||
(&self.plateau_init.pipeline, &pinit_bg),
|
||||
] {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, bg, &[]);
|
||||
pass.dispatch_workgroups(groups.0, groups.1, 1);
|
||||
}
|
||||
|
||||
pass.set_pipeline(&self.plateau_step.pipeline);
|
||||
for i in 0..plateau_steps {
|
||||
let bg = if i % 2 == 0 { &pstep_ab } else { &pstep_ba };
|
||||
pass.set_bind_group(0, bg, &[]);
|
||||
pass.dispatch_workgroups(groups.0, groups.1, 1);
|
||||
}
|
||||
|
||||
pass.set_pipeline(&self.flow.pipeline);
|
||||
pass.set_bind_group(0, &flow_bg, &[]);
|
||||
pass.dispatch_workgroups(groups.0, groups.1, 1);
|
||||
|
||||
pass.set_pipeline(&self.jump.pipeline);
|
||||
for i in 0..jumps {
|
||||
let bg = if i % 2 == 0 { &jump_ab } else { &jump_ba };
|
||||
pass.set_bind_group(0, bg, &[]);
|
||||
pass.dispatch_workgroups(groups.0, groups.1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
|
||||
// An odd number of jumps leaves the result in B.
|
||||
let labels = if jumps % 2 == 1 { parent_b } else { parent_a };
|
||||
|
||||
Ok(Segmentation {
|
||||
ctx: self.ctx.clone(),
|
||||
width,
|
||||
height,
|
||||
labels,
|
||||
gradient,
|
||||
})
|
||||
}
|
||||
|
||||
fn buffer(&self, label: &str, size: u64, copyable: bool) -> wgpu::Buffer {
|
||||
let mut usage = wgpu::BufferUsages::STORAGE;
|
||||
if copyable {
|
||||
usage |= wgpu::BufferUsages::COPY_SRC;
|
||||
}
|
||||
self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn bind3(
|
||||
&self,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
params: &wgpu::Buffer,
|
||||
a: (u32, &wgpu::Buffer),
|
||||
b: (u32, &wgpu::Buffer),
|
||||
c: (u32, &wgpu::Buffer),
|
||||
) -> wgpu::BindGroup {
|
||||
self.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("watershed-bg3"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: a.0,
|
||||
resource: a.1.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: b.0,
|
||||
resource: b.1.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: c.0,
|
||||
resource: c.1.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn bind(
|
||||
&self,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
params: &wgpu::Buffer,
|
||||
in_binding: u32,
|
||||
input: &wgpu::Buffer,
|
||||
out_binding: u32,
|
||||
output: &wgpu::Buffer,
|
||||
) -> wgpu::BindGroup {
|
||||
self.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("watershed-bg"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: in_binding,
|
||||
resource: input.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: out_binding,
|
||||
resource: output.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of one segmentation: a basin label per pixel, on the GPU.
|
||||
pub struct Segmentation {
|
||||
/// Only [`Self::read_field`] reads this, so a build without `readback`
|
||||
/// carries it unread. That is now the ordinary build: dr-ui used to turn
|
||||
/// the feature on for the whole workspace and stopped when S1 removed the
|
||||
/// display readback, which is what made the field look dead.
|
||||
#[cfg_attr(not(any(test, feature = "readback")), allow(dead_code))]
|
||||
ctx: GpuContext,
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Per pixel, the linear index of its basin root. Sparse — compacted by
|
||||
/// [`dr_segment::RegionField::from_roots`].
|
||||
labels: wgpu::Buffer,
|
||||
/// As with `ctx` above: read only by [`Self::read_field`].
|
||||
#[cfg_attr(not(any(test, feature = "readback")), allow(dead_code))]
|
||||
gradient: wgpu::Buffer,
|
||||
}
|
||||
|
||||
impl Segmentation {
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
/// The label buffer, for a shader that masks by region id.
|
||||
pub fn labels(&self) -> &wgpu::Buffer {
|
||||
&self.labels
|
||||
}
|
||||
|
||||
/// Build the region adjacency graph, reading the labels back to the CPU.
|
||||
///
|
||||
/// # Why this has its own feature rather than sharing `readback`
|
||||
///
|
||||
/// `readback` gates [`crate::AdjustPass::read_pixels`], which is the
|
||||
/// per-frame display round-trip AC-8 exists to forbid. This is a different
|
||||
/// transfer with different economics, and sharing one switch would have
|
||||
/// forced a build wanting local masking to also unlock the one thing the
|
||||
/// architecture is built around never doing.
|
||||
///
|
||||
/// What this transfer actually is: **once per image, on a worker, off the
|
||||
/// frame path.** Nothing in the render loop waits on it, and the result is
|
||||
/// a region graph of a few thousand nodes that every later interaction
|
||||
/// reads from the CPU anyway.
|
||||
///
|
||||
/// What it is *not* is finished. F3 in docs/segmentation.md §12 stands:
|
||||
/// the adjacency accumulation belongs on the GPU with atomics, and until
|
||||
/// it moves there a segmentation costs one full-resolution transfer of the
|
||||
/// label and gradient buffers. That is a real cost on a phone and the
|
||||
/// reason this is named for what it does rather than hidden behind the
|
||||
/// general switch.
|
||||
#[cfg(any(test, feature = "segment-readback"))]
|
||||
pub fn read_field(&self) -> Result<dr_segment::RegionField, GpuError> {
|
||||
let n = (self.width * self.height) as usize;
|
||||
let roots: Vec<u32> = read_buffer(&self.ctx, &self.labels, n)?;
|
||||
let gradient: Vec<f32> = read_buffer(&self.ctx, &self.gradient, n)?;
|
||||
Ok(dr_segment::RegionField::from_roots(
|
||||
&roots,
|
||||
&gradient,
|
||||
self.width as usize,
|
||||
self.height as usize,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute(
|
||||
ctx: &GpuContext,
|
||||
module: &wgpu::ShaderModule,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
entry: &str,
|
||||
) -> wgpu::ComputePipeline {
|
||||
let pipeline_layout = ctx
|
||||
.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("watershed-layout"),
|
||||
bind_group_layouts: &[Some(layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
ctx.device
|
||||
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(entry),
|
||||
layout: Some(&pipeline_layout),
|
||||
module,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The proxy size for a source, preserving aspect and never upscaling.
|
||||
fn proxy_size(src_w: u32, src_h: u32, max_edge: u32) -> (u32, u32) {
|
||||
let longest = src_w.max(src_h);
|
||||
if longest <= max_edge || longest == 0 {
|
||||
return (src_w.max(1), src_h.max(1));
|
||||
}
|
||||
let scale = f64::from(max_edge) / f64::from(longest);
|
||||
(
|
||||
((f64::from(src_w) * scale).round() as u32).max(1),
|
||||
((f64::from(src_h) * scale).round() as u32).max(1),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "segment-readback"))]
|
||||
fn read_buffer<T: bytemuck::Pod>(
|
||||
ctx: &GpuContext,
|
||||
buffer: &wgpu::Buffer,
|
||||
len: usize,
|
||||
) -> Result<Vec<T>, GpuError> {
|
||||
let size = (len * std::mem::size_of::<T>()) as u64;
|
||||
let staging = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("watershed-readback"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let mut enc = ctx.device.create_command_encoder(&Default::default());
|
||||
enc.copy_buffer_to_buffer(buffer, 0, &staging, 0, size);
|
||||
ctx.queue.submit(Some(enc.finish()));
|
||||
|
||||
let slice = staging.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.map_err(|e| GpuError::Readback(e.to_string()))?;
|
||||
rx.recv()
|
||||
.map_err(|e| GpuError::Readback(e.to_string()))?
|
||||
.map_err(|e| GpuError::Readback(e.to_string()))?;
|
||||
|
||||
let data = slice.get_mapped_range();
|
||||
let out = bytemuck::cast_slice::<u8, T>(&data).to_vec();
|
||||
drop(data);
|
||||
staging.unmap();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_segment::MergeTree;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_preserves_aspect_and_never_upscales() {
|
||||
assert_eq!(proxy_size(6000, 4000, 1600), (1600, 1067));
|
||||
assert_eq!(proxy_size(4000, 6000, 1600), (1067, 1600));
|
||||
// A thumbnail must not be blown up to the proxy size — there is no
|
||||
// detail there to find basins in.
|
||||
assert_eq!(proxy_size(800, 600, 1600), (800, 600));
|
||||
assert_eq!(proxy_size(0, 0, 1600), (1, 1));
|
||||
}
|
||||
|
||||
/// Two flat halves split by a hard vertical edge.
|
||||
fn two_tone(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut px = Vec::with_capacity((w * h * 4) as usize);
|
||||
for _ in 0..h {
|
||||
for x in 0..w {
|
||||
let v = if x < w / 2 { 30u8 } else { 220u8 };
|
||||
px.extend_from_slice(&[v, v, v, 255]);
|
||||
}
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hard_edge_produces_two_regions_at_the_top_of_the_ladder() {
|
||||
// The end-to-end property, on an image whose answer is not in doubt:
|
||||
// whatever the watershed does with texture, it must not lose an edge
|
||||
// this obvious, and the coarsest non-trivial cut must be exactly the
|
||||
// two halves.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let (w, h) = (64u32, 64u32);
|
||||
let src = DemosaicedImage::from_rgba8(&ctx, &two_tone(w, h), w, h).expect("source");
|
||||
|
||||
let pass = SegmentPass::new(&ctx).expect("segment pass");
|
||||
let seg = pass.run(&src, SegmentOptions::default()).expect("run");
|
||||
assert_eq!(seg.size(), (w, h));
|
||||
|
||||
let field = seg.read_field().expect("read field");
|
||||
let tree = MergeTree::build(&field);
|
||||
let px = field.apply(&tree.cut_to(2));
|
||||
|
||||
for y in 0..h as usize {
|
||||
let left = px[y * w as usize];
|
||||
let right = px[y * w as usize + w as usize - 1];
|
||||
assert_ne!(left, right, "the two halves must not share a region");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_image_does_not_fragment() {
|
||||
// The noise case in miniature. A gradient of zero everywhere is one
|
||||
// enormous plateau, which is exactly where a watershed without a
|
||||
// strict tie-break either hangs or shatters into per-pixel basins.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let (w, h) = (32u32, 32u32);
|
||||
let flat = vec![128u8; (w * h * 4) as usize];
|
||||
let src = DemosaicedImage::from_rgba8(&ctx, &flat, w, h).expect("source");
|
||||
|
||||
let pass = SegmentPass::new(&ctx).expect("segment pass");
|
||||
let seg = pass.run(&src, SegmentOptions::default()).expect("run");
|
||||
let field = seg.read_field().expect("read field");
|
||||
|
||||
assert_eq!(
|
||||
field.region_count, 1,
|
||||
"a plateau should resolve to one basin, not {}",
|
||||
field.region_count
|
||||
);
|
||||
}
|
||||
|
||||
/// A flat disc on flat ground: two plateaux and one boundary between
|
||||
/// them. Nothing here has a downhill direction except at the rim.
|
||||
/// A linear ramp between two flat fields.
|
||||
///
|
||||
/// The watershed runs on gradient *magnitude*, and that changes which
|
||||
/// images contain a plateau worth resolving. A flat region of the picture
|
||||
/// has gradient zero — the global minimum — and a plateau at the minimum
|
||||
/// has no descending exit at all, which makes it a single basin by
|
||||
/// definition with nothing for lower-completion to do. The plateaux that
|
||||
/// do have an exit are regions of constant *non-zero* gradient: linear
|
||||
/// ramps. So that is what this builds.
|
||||
fn ramp(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut px = vec![0u8; (w * h * 4) as usize];
|
||||
let (lo, hi) = (w / 4, w - w / 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let v = if x < lo {
|
||||
40u8
|
||||
} else if x >= hi {
|
||||
210u8
|
||||
} else {
|
||||
// Constant slope, so the gradient is constant and
|
||||
// non-zero across the whole band.
|
||||
(40.0 + (x - lo) as f32 * (170.0 / (hi - lo) as f32)) as u8
|
||||
};
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
px[i] = v;
|
||||
px[i + 1] = v;
|
||||
px[i + 2] = v;
|
||||
px[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
px
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "the plateau pass is a measured no-op; see docs/segmentation.md §12"]
|
||||
fn lower_completion_drains_a_plateau_instead_of_shattering_it() {
|
||||
// F1, asserted rather than eyeballed, and asserted at the level where
|
||||
// it matters.
|
||||
//
|
||||
// Two claims, because they are different claims. First: carrying the
|
||||
// distance inward genuinely reduces fragmentation — a plateau with an
|
||||
// exit now drains to it instead of fanning into diagonal chains.
|
||||
// Second, and the one a user would notice: whatever fragments survive
|
||||
// are separated by zero-height saddles, so the hierarchy merges them
|
||||
// at its very first steps and the plateau reads as one region.
|
||||
//
|
||||
// The second claim is what makes the first one's *residue* tolerable.
|
||||
// A perfectly flat regional minimum — the inside of a uniform disc,
|
||||
// with no exit anywhere — cannot be drained by a distance that has
|
||||
// nowhere to descend to, and collapsing it fully would need connected
|
||||
// component labelling rather than a local rule. It is not worth it:
|
||||
// see docs/segmentation.md §12.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let (w, h) = (96u32, 96u32);
|
||||
let src = DemosaicedImage::from_rgba8(&ctx, &ramp(w, h), w, h).expect("source");
|
||||
let pass = SegmentPass::new(&ctx).expect("segment pass");
|
||||
|
||||
let labels_in = |labels: &[u32], inside: bool| {
|
||||
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let d = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt();
|
||||
let take = if inside {
|
||||
d < w as f32 * 0.20
|
||||
} else {
|
||||
d > w as f32 * 0.42
|
||||
};
|
||||
if take {
|
||||
seen.insert(labels[(y * w + x) as usize]);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
};
|
||||
|
||||
let field = |iterations: u32| {
|
||||
pass.run(
|
||||
&src,
|
||||
SegmentOptions {
|
||||
plateau_iterations: iterations,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("run")
|
||||
.read_field()
|
||||
.expect("field")
|
||||
};
|
||||
|
||||
let shallow = field(1);
|
||||
let deep = field(64);
|
||||
|
||||
// Before anything else: does the pass change the labelling at all? If
|
||||
// the distance field were never populated — a binding astray, a level
|
||||
// test that never matches — every downstream claim would be excused
|
||||
// by a no-op rather than tested. This is the one assertion that
|
||||
// cannot pass vacuously.
|
||||
let differs = shallow
|
||||
.labels
|
||||
.iter()
|
||||
.zip(deep.labels.iter())
|
||||
.filter(|(a, b)| a != b)
|
||||
.count();
|
||||
assert!(
|
||||
differs > 0,
|
||||
"the plateau distance changed no pixel's basin, so the pass is a \
|
||||
no-op: {} pixels, {} differ",
|
||||
shallow.labels.len(),
|
||||
differs
|
||||
);
|
||||
|
||||
// Claim one: fewer basins, because plateaux with an exit now use it.
|
||||
assert!(
|
||||
deep.region_count < shallow.region_count,
|
||||
"carrying the distance inward should reduce fragmentation: \
|
||||
{} basins against {}",
|
||||
deep.region_count,
|
||||
shallow.region_count
|
||||
);
|
||||
|
||||
// Claim two: what survives costs nothing, because the hierarchy
|
||||
// dissolves it immediately.
|
||||
let tree = MergeTree::build(&deep);
|
||||
let grouped = deep.apply(&tree.cut_to(2));
|
||||
let inside = labels_in(&grouped, true);
|
||||
let outside = labels_in(&grouped, false);
|
||||
assert_eq!(inside.len(), 1, "the disc should read as one region");
|
||||
assert_eq!(outside.len(), 1, "the ground should read as one region");
|
||||
assert_ne!(inside, outside, "and they must not be the same region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_image_segments_identically_twice() {
|
||||
// M5 on one device — the weaker half of the determinism question, but
|
||||
// the half that catches a race in the pointer jumping. Cross-vendor
|
||||
// is the part that needs hardware this test cannot assume.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let (w, h) = (48u32, 48u32);
|
||||
let src = DemosaicedImage::from_rgba8(&ctx, &two_tone(w, h), w, h).expect("source");
|
||||
let pass = SegmentPass::new(&ctx).expect("segment pass");
|
||||
|
||||
let a = pass
|
||||
.run(&src, SegmentOptions::default())
|
||||
.expect("run")
|
||||
.read_field()
|
||||
.expect("field");
|
||||
let b = pass
|
||||
.run(&src, SegmentOptions::default())
|
||||
.expect("run")
|
||||
.read_field()
|
||||
.expect("field");
|
||||
|
||||
assert_eq!(a, b, "segmentation must be reproducible run to run");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Black/white normalisation and Bayer demosaic, in one pass.
|
||||
//
|
||||
// Input is the raw sensor readout as packed u16 samples — one per photosite,
|
||||
// in CFA order. Output is linear scene-referred RGBA16Float in *camera*
|
||||
// colour space; the camera→sRGB matrix belongs to the adjust pass, so this
|
||||
// stage is purely about reconstructing three channels from one.
|
||||
//
|
||||
// The algorithm is Malvar-He-Cutler (ICASSP 2004): bilinear interpolation
|
||||
// plus a Laplacian correction taken from the channel that *is* sampled at
|
||||
// each site. One 5x5 neighbourhood per pixel, and dramatically better than
|
||||
// bilinear on edges — bilinear leaves visible zippering on any high-contrast
|
||||
// boundary, which on a 24 MP file is the first thing seen at 1:1.
|
||||
//
|
||||
// Kernel coefficients below are the paper's, all over 8.
|
||||
|
||||
struct DemosaicParams {
|
||||
// Dimensions of the *cropped* output, in pixels.
|
||||
width: u32,
|
||||
height: u32,
|
||||
// Origin of the crop within the sensor readout, in photosites. Added to
|
||||
// every read so the masked border is never sampled.
|
||||
crop_x: u32,
|
||||
crop_y: u32,
|
||||
// Row stride of the input, in samples.
|
||||
stride: u32,
|
||||
// CFA layout of the *cropped* image, already re-phased for the crop
|
||||
// origin by dr-decode: 0=RGGB, 1=BGGR, 2=GRBG, 3=GBRG.
|
||||
pattern: u32,
|
||||
_pad0: u32,
|
||||
_pad1: u32,
|
||||
// Per-CFA-position black levels, indexed by (y&1)*2 + (x&1).
|
||||
black: vec4<f32>,
|
||||
// Reciprocal of (white - black) per position, precomputed on the CPU so
|
||||
// the shader does no division.
|
||||
inv_range: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<storage, read> raw: array<u32>;
|
||||
@group(0) @binding(1) var<uniform> params: DemosaicParams;
|
||||
@group(0) @binding(2) var output: texture_storage_2d<rgba16float, write>;
|
||||
|
||||
// Colour of the photosite at (x, y): 0=R, 1=G, 2=B.
|
||||
//
|
||||
// Each pattern is its 2x2 cell read row-major, packed two bits per entry so
|
||||
// the lookup is an index and a shift rather than a branch.
|
||||
fn colour_at(x: u32, y: u32) -> u32 {
|
||||
let cell = (y & 1u) * 2u + (x & 1u);
|
||||
// RGGB = R,G,G,B -> 0,1,1,2 ; BGGR = 2,1,1,0 ; GRBG = 1,0,2,1 ; GBRG = 1,2,0,1
|
||||
// Entry i occupies bits [2i, 2i+1], so the cell order reads
|
||||
// right-to-left in hex. Verified against a table rather than derived by
|
||||
// eye — two of these were wrong on the first attempt.
|
||||
var packed: u32;
|
||||
switch params.pattern {
|
||||
case 0u: { packed = 0x94u; } // RGGB -> [0,1,1,2]
|
||||
case 1u: { packed = 0x16u; } // BGGR -> [2,1,1,0]
|
||||
case 2u: { packed = 0x61u; } // GRBG -> [1,0,2,1]
|
||||
default: { packed = 0x49u; } // GBRG -> [1,2,0,1]
|
||||
}
|
||||
return (packed >> (cell * 2u)) & 3u;
|
||||
}
|
||||
|
||||
// Whether the row through (x, y) is one carrying red photosites.
|
||||
//
|
||||
// Needed at green sites, where red lies along one axis and blue along the
|
||||
// other, and which is which depends on the pattern.
|
||||
fn red_is_horizontal(x: u32, y: u32) -> bool {
|
||||
// The horizontal neighbour of a green site.
|
||||
return colour_at(x + 1u, y) == 0u;
|
||||
}
|
||||
|
||||
// Read one photosite, normalised to [0, 1] against its own black level.
|
||||
//
|
||||
// Coordinates are relative to the crop origin. A 5x5 window at the image edge
|
||||
// reflects rather than reading masked photosites or running off the buffer.
|
||||
fn sample(ix: i32, iy: i32) -> f32 {
|
||||
let w = i32(params.width);
|
||||
let h = i32(params.height);
|
||||
|
||||
// Reflect at the borders, preserving CFA parity: reflecting by an even
|
||||
// distance keeps the mirrored sample the same colour as the one it
|
||||
// stands in for. Clamping instead would flatten the correction term and
|
||||
// leave a visible one-pixel seam along each edge.
|
||||
var cx = ix;
|
||||
var cy = iy;
|
||||
if (cx < 0) { cx = -cx; }
|
||||
if (cy < 0) { cy = -cy; }
|
||||
if (cx > w - 1) { cx = 2 * (w - 1) - cx; }
|
||||
if (cy > h - 1) { cy = 2 * (h - 1) - cy; }
|
||||
cx = clamp(cx, 0, w - 1);
|
||||
cy = clamp(cy, 0, h - 1);
|
||||
|
||||
let sx = u32(cx) + params.crop_x;
|
||||
let sy = u32(cy) + params.crop_y;
|
||||
let index = sy * params.stride + sx;
|
||||
|
||||
// Samples are u16, packed two per u32 word.
|
||||
let word = raw[index >> 1u];
|
||||
let raw_value = select(word & 0xFFFFu, word >> 16u, (index & 1u) == 1u);
|
||||
|
||||
// Black level and range are per CFA position. Subtracting black can go
|
||||
// negative on sensor noise — real signal below the black point — so the
|
||||
// result is clamped rather than allowed to wrap.
|
||||
let cell = (u32(cy) & 1u) * 2u + (u32(cx) & 1u);
|
||||
let value = (f32(raw_value) - params.black[cell]) * params.inv_range[cell];
|
||||
|
||||
// **Clamped at the top as well, and that is what stops blown highlights
|
||||
// going pink.** Sensors read above their declared white level — on a
|
||||
// Canon 6D CR2 the data reaches 16383 against a white of 15070 — so a
|
||||
// saturated pixel normalises to about 1.1 rather than 1.0.
|
||||
//
|
||||
// Left unclamped it survives the white balance, where red is multiplied
|
||||
// by ~1.93 and blue by ~1.68 against green's 1.0, and then the camera
|
||||
// matrix. Red and blue clip at the end of the pipeline; green, whose
|
||||
// matrix row is far less positive-heavy, does not. Red and blue high with
|
||||
// green low is magenta, and a clipped highlight came back pink.
|
||||
//
|
||||
// Clamping here makes a blown pixel saturate *neutrally*: all three
|
||||
// channels reach 1.0 together and the highlight is white, which is what a
|
||||
// blown highlight looks like and what every other developer produces.
|
||||
return clamp(value, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= params.width || gid.y >= params.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
let x = i32(gid.x);
|
||||
let y = i32(gid.y);
|
||||
|
||||
let c = sample(x, y);
|
||||
|
||||
// 5x5 neighbourhood.
|
||||
let n1 = sample(x, y - 1);
|
||||
let s1 = sample(x, y + 1);
|
||||
let w1 = sample(x - 1, y);
|
||||
let e1 = sample(x + 1, y);
|
||||
|
||||
let n2 = sample(x, y - 2);
|
||||
let s2 = sample(x, y + 2);
|
||||
let w2 = sample(x - 2, y);
|
||||
let e2 = sample(x + 2, y);
|
||||
|
||||
let nw = sample(x - 1, y - 1);
|
||||
let ne = sample(x + 1, y - 1);
|
||||
let sw = sample(x - 1, y + 1);
|
||||
let se = sample(x + 1, y + 1);
|
||||
|
||||
let axial1 = n1 + s1 + w1 + e1;
|
||||
let diag1 = nw + ne + sw + se;
|
||||
let vert2 = n2 + s2;
|
||||
let horiz2 = w2 + e2;
|
||||
|
||||
let colour = colour_at(gid.x, gid.y);
|
||||
var rgb: vec3<f32>;
|
||||
|
||||
if (colour == 1u) {
|
||||
// ---- Green site ----------------------------------------------
|
||||
// Green is measured. Red and blue are interpolated from their own
|
||||
// axis, with a correction from the green Laplacian.
|
||||
//
|
||||
// Malvar "G at R/B locations" kernels, transposed per axis:
|
||||
// chroma along the row: (5c + 4(w1+e1) - (nw+ne+sw+se) - (n2+s2) + 0.5(w2+e2)) / 8
|
||||
let along_row =
|
||||
(5.0 * c + 4.0 * (w1 + e1) - diag1 - vert2 + 0.5 * horiz2) * 0.125;
|
||||
let along_col =
|
||||
(5.0 * c + 4.0 * (n1 + s1) - diag1 - horiz2 + 0.5 * vert2) * 0.125;
|
||||
|
||||
let red_horizontal = red_is_horizontal(gid.x, gid.y);
|
||||
let r = select(along_col, along_row, red_horizontal);
|
||||
let b = select(along_row, along_col, red_horizontal);
|
||||
rgb = vec3<f32>(r, c, b);
|
||||
} else {
|
||||
// ---- Red or blue site ----------------------------------------
|
||||
// Green at an R/B site: bilinear on the axial neighbours, corrected
|
||||
// by the centre channel's Laplacian.
|
||||
// (4c + 2(n1+s1+w1+e1) - (n2+s2+w2+e2)) / 8
|
||||
let green = (4.0 * c + 2.0 * axial1 - (vert2 + horiz2)) * 0.125;
|
||||
|
||||
// The opposite chroma sits on the diagonals.
|
||||
// (6c + 2(nw+ne+sw+se) - 1.5(n2+s2+w2+e2)) / 8
|
||||
let opposite = (6.0 * c + 2.0 * diag1 - 1.5 * (vert2 + horiz2)) * 0.125;
|
||||
|
||||
if (colour == 0u) {
|
||||
rgb = vec3<f32>(c, green, opposite);
|
||||
} else {
|
||||
rgb = vec3<f32>(opposite, green, c);
|
||||
}
|
||||
}
|
||||
|
||||
// The correction term can overshoot below zero near clipped highlights.
|
||||
// Negative light is not meaningful, and carrying it forward makes the
|
||||
// ratio-based operations downstream (white balance, saturation) misbehave.
|
||||
rgb = max(rgb, vec3<f32>(0.0));
|
||||
|
||||
textureStore(output, vec2<i32>(x, y), vec4<f32>(rgb, 1.0));
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Placeholder compute shader — stands in for the develop pipeline.
|
||||
//
|
||||
// Its only job is to prove the path: a compute shader writes a storage
|
||||
// texture, and that texture reaches the screen without a CPU round-trip
|
||||
// (ARCH §6.1). Replaced by real pipeline stages in v0.2.
|
||||
|
||||
struct Params {
|
||||
width: u32,
|
||||
height: u32,
|
||||
// Animates so it is visually obvious the compute pass runs every frame
|
||||
// rather than a stale texture being redisplayed.
|
||||
phase: f32,
|
||||
_pad: f32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var output: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(1) var<uniform> params: Params;
|
||||
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= params.width || gid.y >= params.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
let uv = vec2<f32>(
|
||||
f32(gid.x) / f32(params.width),
|
||||
f32(gid.y) / f32(params.height),
|
||||
);
|
||||
|
||||
// Warm dark ground with a moving highlight — deliberately unlike a test
|
||||
// pattern, so a stuck frame is obvious at a glance.
|
||||
let d = distance(uv, vec2<f32>(0.5 + 0.25 * cos(params.phase), 0.5 + 0.25 * sin(params.phase)));
|
||||
let glow = 1.0 - smoothstep(0.0, 0.55, d);
|
||||
|
||||
let base = vec3<f32>(0.08, 0.07, 0.06);
|
||||
let accent = vec3<f32>(0.69, 0.23, 0.15);
|
||||
let rgb = base + accent * glow * (0.35 + 0.65 * uv.y);
|
||||
|
||||
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(rgb, 1.0));
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// TRACES: FR-DSP-7
|
||||
// A reduction of the display frame into bin counts, run where the pixels are.
|
||||
//
|
||||
// FR-DSP-7 does not merely permit this shape, it names it: "these derive from a
|
||||
// GPU-side reduction into a small buffer", because the alternative — dragging
|
||||
// the frame back across the bus to count it — is the per-frame round-trip
|
||||
// ARCH §6.1 forbids and the bottleneck darktable documents. What leaves the
|
||||
// device here is 4104 bytes whatever the image size.
|
||||
//
|
||||
// **Counted per workgroup first, then merged.** A photograph is not noise: a
|
||||
// clear sky puts tens of thousands of adjacent pixels in one bin, and having
|
||||
// every invocation contend for that single global atomic serialises the whole
|
||||
// dispatch. Each workgroup therefore tallies its own 256 pixels into workgroup
|
||||
// memory — where the atomic is cheap and the contention is between 256 threads
|
||||
// rather than two million — and contributes one add per non-empty bin at the
|
||||
// end. Four kilobytes of workgroup storage against a 16 KB floor.
|
||||
|
||||
const BINS: u32 = 256u;
|
||||
// Two counters past the four channels: how many pixels clip at each end. They
|
||||
// live in the same buffer because they are gathered from the same texel read
|
||||
// and would otherwise need a second reduction to answer a question the first
|
||||
// one already had the data for.
|
||||
const CLIPPED_HIGH: u32 = 4u * BINS;
|
||||
const CLIPPED_LOW: u32 = 4u * BINS + 1u;
|
||||
const SLOTS: u32 = 4u * BINS + 2u;
|
||||
// 16x16. Stated as a constant because the clear and merge loops below stride by
|
||||
// it, and a workgroup size that disagreed would leave slots uncleared.
|
||||
const THREADS: u32 = 256u;
|
||||
|
||||
struct Dims {
|
||||
width: u32,
|
||||
height: u32,
|
||||
// std140 rounds a uniform block up to 16 bytes; named rather than left
|
||||
// implicit so the Rust side's padding is visibly the same shape.
|
||||
pad_0: u32,
|
||||
pad_1: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var<storage, read_write> bins: array<atomic<u32>>;
|
||||
@group(0) @binding(2) var<uniform> dims: Dims;
|
||||
|
||||
var<workgroup> tile: array<atomic<u32>, SLOTS>;
|
||||
|
||||
/// The 8-bit level a sampled texel came from.
|
||||
///
|
||||
/// The source is `Rgba8Unorm`, so the sampler hands back exactly n/255 and this
|
||||
/// recovers n. `floor(x + 0.5)` rather than `round`, which ties to even in WGSL
|
||||
/// and away from zero in Rust — a difference invisible except on an exact tie,
|
||||
/// which is precisely the kind of disagreement that makes a cross-check against
|
||||
/// a CPU reference fail once in a thousand runs and look like flakiness.
|
||||
fn level(v: f32) -> u32 {
|
||||
return u32(clamp(floor(v * 255.0 + 0.5), 0.0, 255.0));
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16, 1)
|
||||
fn main(
|
||||
@builtin(global_invocation_id) gid: vec3<u32>,
|
||||
@builtin(local_invocation_index) lid: u32,
|
||||
) {
|
||||
for (var i = lid; i < SLOTS; i = i + THREADS) {
|
||||
atomicStore(&tile[i], 0u);
|
||||
}
|
||||
workgroupBarrier();
|
||||
|
||||
// Guarded rather than dispatched exactly: the workgroup is 16x16 and an
|
||||
// image is not, so the last row and column of workgroups run off the edge.
|
||||
if (gid.x < dims.width && gid.y < dims.height) {
|
||||
let texel = textureLoad(source, vec2<i32>(i32(gid.x), i32(gid.y)), 0);
|
||||
let r = level(texel.r);
|
||||
let g = level(texel.g);
|
||||
let b = level(texel.b);
|
||||
|
||||
// Rec.709 luma in 8.8 fixed point. 54 + 183 + 19 is exactly 256, so the
|
||||
// weights sum to unity and the shift can never carry past 255.
|
||||
//
|
||||
// Integer rather than float on purpose (ARCH §6.13): a float weighting
|
||||
// is reproducible only to within the vendor's rounding, and the whole
|
||||
// value of the CPU cross-check in the tests is that it is exact.
|
||||
//
|
||||
// Weighted on the *encoded* values, not on linear light. That is what
|
||||
// every histogram a photographer has read is: the axis is the output
|
||||
// level, so a mid-grey has to sit in the middle of it.
|
||||
let y = (54u * r + 183u * g + 19u * b) >> 8u;
|
||||
|
||||
atomicAdd(&tile[r], 1u);
|
||||
atomicAdd(&tile[BINS + g], 1u);
|
||||
atomicAdd(&tile[2u * BINS + b], 1u);
|
||||
atomicAdd(&tile[3u * BINS + y], 1u);
|
||||
|
||||
// Any channel, not all three: a blown red channel is detail that is
|
||||
// gone, whatever green and blue still hold. Counting only neutral white
|
||||
// would stay silent on exactly the saturated highlight — a sunset, a
|
||||
// red jersey — that clips first and recovers worst.
|
||||
if (r == 255u || g == 255u || b == 255u) {
|
||||
atomicAdd(&tile[CLIPPED_HIGH], 1u);
|
||||
}
|
||||
if (r == 0u || g == 0u || b == 0u) {
|
||||
atomicAdd(&tile[CLIPPED_LOW], 1u);
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
|
||||
for (var i = lid; i < SLOTS; i = i + THREADS) {
|
||||
let count = atomicLoad(&tile[i]);
|
||||
// Most bins of most workgroups are empty — a 16x16 tile can touch 256
|
||||
// of 1026 slots at the very most, and usually far fewer.
|
||||
if (count != 0u) {
|
||||
atomicAdd(&bins[i], count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// Rasterise one local-adjustment mask into a layer of the mask array.
|
||||
//
|
||||
// ARCH §5.4: every mask becomes pixels here and never in CPU memory. One draw
|
||||
// per layer, each targeting its own array slice, run only when a mask's
|
||||
// *shape* changes — moving a slider on a masked layer re-runs the adjust
|
||||
// shader and not this one.
|
||||
//
|
||||
// # Why this is a render pass and not a compute one
|
||||
//
|
||||
// The natural shape for this is a compute shader writing a storage texture,
|
||||
// and the format is what rules that out: **R8Unorm is not a core storage
|
||||
// format**, so a compute path has to widen the mask to R32Float or RGBA8 —
|
||||
// four bytes per pixel per layer. At eight layers over a 24 MP export that is
|
||||
// 768 MB of masks, against 192 MB at one byte. A colour attachment takes
|
||||
// R8Unorm happily, so the mask stays one byte and the pass becomes a
|
||||
// full-screen triangle.
|
||||
//
|
||||
// The array slice is chosen by the *view* the caller attaches, so there is no
|
||||
// slot uniform here — one less thing that can disagree with the shader.
|
||||
|
||||
struct MaskParams {
|
||||
// Output size, which is the render size rather than the segmentation's.
|
||||
width: u32,
|
||||
height: u32,
|
||||
// Label field size. Different from the above: the watershed runs at a
|
||||
// proxy resolution, and the mask is drawn at whatever the display or the
|
||||
// export asked for.
|
||||
label_width: u32,
|
||||
label_height: u32,
|
||||
|
||||
// 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush.
|
||||
//
|
||||
// A brush does not read this — it has its own entry points, because it is
|
||||
// the one mask that is not a function of the whole frame — but it is set
|
||||
// anyway so a captured frame says which kind of mask a pass was drawing.
|
||||
mode: u32,
|
||||
// How many regions the label field holds, so an out-of-range label is
|
||||
// caught rather than read past the end of `selected`.
|
||||
region_count: u32,
|
||||
// Softening applied to a region mask, in output pixels.
|
||||
feather: f32,
|
||||
// 0 hard, 1 linear, 2 smooth, 3 gaussian, 4 exponential. Kept in step with
|
||||
// `falloff_code` on the Rust side.
|
||||
falloff: u32,
|
||||
|
||||
// Geometry. Meaning depends on `mode`. The centre is in normalised 0..1
|
||||
// coordinates; every distance below it is in the isotropic frame units
|
||||
// `frame_delta` establishes.
|
||||
centre: vec2<f32>,
|
||||
// Linear: (cos, sin) of the ramp direction. Radial: semi-axes.
|
||||
axis: vec2<f32>,
|
||||
// Linear: ramp width. Radial: edge falloff as a fraction of the radius.
|
||||
softness: f32,
|
||||
// Radial only: rotation of the ellipse.
|
||||
angle: f32,
|
||||
_pad1: vec2<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: MaskParams;
|
||||
// Compacted region id per pixel of the label field. Compacted rather than the
|
||||
// watershed's raw basin roots: the roots are sparse indices into pixel space,
|
||||
// so indexing a per-region array by one would need a table as large as the
|
||||
// image. The compaction happens once, when the segmentation is built.
|
||||
@group(0) @binding(1) var<storage, read> labels: array<u32>;
|
||||
// One entry per region: non-zero if the region is in this mask. Small — a few
|
||||
// thousand bytes — which is what makes changing a selection cheap.
|
||||
@group(0) @binding(2) var<storage, read> selected: array<u32>;
|
||||
// The **signed distance** from one subject's boundary, in proxy pixels:
|
||||
// positive inside, negative outside. A 1x1 placeholder when the layer is not a
|
||||
// subject — the binding is fixed, and a second pipeline differing only in what
|
||||
// it ignores would be worse than a wasted texel.
|
||||
//
|
||||
// A distance field rather than a finished alpha is what makes growing,
|
||||
// shrinking and feathering free: each is arithmetic on this, so a slider moves
|
||||
// a uniform instead of rebuilding a mask.
|
||||
@group(0) @binding(3) var subject: texture_2d<f32>;
|
||||
|
||||
// A full-screen triangle rather than a quad: three vertices instead of six,
|
||||
// no shared edge for the rasteriser to crack along, and no vertex buffer.
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let x = f32(i32(i) / 2) * 4.0 - 1.0;
|
||||
let y = f32(i32(i) & 1) * 4.0 - 1.0;
|
||||
return vec4<f32>(x, y, 0.0, 1.0);
|
||||
}
|
||||
|
||||
fn region_at(px: vec2<i32>) -> u32 {
|
||||
// Nearest-neighbour from output space into the label field. Deliberately
|
||||
// not bilinear: region ids are *names*, and the average of region 4 and
|
||||
// region 9 is not region 6.
|
||||
let fx = (f32(px.x) + 0.5) / f32(p.width);
|
||||
let fy = (f32(px.y) + 0.5) / f32(p.height);
|
||||
let lx = clamp(i32(fx * f32(p.label_width)), 0, i32(p.label_width) - 1);
|
||||
let ly = clamp(i32(fy * f32(p.label_height)), 0, i32(p.label_height) - 1);
|
||||
return labels[u32(ly) * p.label_width + u32(lx)];
|
||||
}
|
||||
|
||||
fn in_selection(px: vec2<i32>) -> f32 {
|
||||
let r = region_at(px);
|
||||
if (r >= p.region_count) {
|
||||
return 0.0;
|
||||
}
|
||||
return select(0.0, 1.0, selected[r] != 0u);
|
||||
}
|
||||
|
||||
fn region_mask(px: vec2<i32>) -> f32 {
|
||||
let hard = in_selection(px);
|
||||
if (p.feather <= 0.0) {
|
||||
return hard;
|
||||
}
|
||||
|
||||
// Box-average the binary selection over the feather radius. Cheap, and it
|
||||
// is the whole reason a region mask does not look cut out with scissors:
|
||||
// the watershed boundary is pixel-exact, which is correct and also harsher
|
||||
// than any edit wants at a subject's edge.
|
||||
let r = i32(ceil(p.feather));
|
||||
var total = 0.0;
|
||||
var n = 0.0;
|
||||
for (var dy = -r; dy <= r; dy = dy + 1) {
|
||||
for (var dx = -r; dx <= r; dx = dx + 1) {
|
||||
let q = clamp(
|
||||
px + vec2<i32>(dx, dy),
|
||||
vec2<i32>(0, 0),
|
||||
vec2<i32>(i32(p.width) - 1, i32(p.height) - 1),
|
||||
);
|
||||
total = total + in_selection(q);
|
||||
n = n + 1.0;
|
||||
}
|
||||
}
|
||||
return total / n;
|
||||
}
|
||||
|
||||
// Offset from a gradient's centre, in the frame's own **isotropic** units:
|
||||
// y spans 0..1 and x spans 0..aspect, so a step of the same length means the
|
||||
// same distance whichever way it points.
|
||||
//
|
||||
// Without this the geometry lives in raw 0..1, where one axis is compressed
|
||||
// against the other by the aspect ratio — so a 45° ramp is not at 45° on
|
||||
// anything but a square frame, and a radial with equal radii draws an ellipse.
|
||||
// Both faults are invisible in the stored numbers and obvious the moment a
|
||||
// handle is dragged on a photograph, which is what this exists for.
|
||||
fn frame_delta(uv: vec2<f32>) -> vec2<f32> {
|
||||
let aspect = vec2<f32>(f32(p.width) / f32(max(p.height, 1u)), 1.0);
|
||||
return (uv - p.centre) * aspect;
|
||||
}
|
||||
|
||||
fn linear_mask(uv: vec2<f32>) -> f32 {
|
||||
// Signed distance along the ramp direction, from the centre.
|
||||
let d = dot(frame_delta(uv), p.axis);
|
||||
if (p.softness <= 0.0) {
|
||||
return select(0.0, 1.0, d >= 0.0);
|
||||
}
|
||||
return smoothstep(-p.softness * 0.5, p.softness * 0.5, d);
|
||||
}
|
||||
|
||||
fn radial_mask(uv: vec2<f32>) -> f32 {
|
||||
let ca = cos(-p.angle);
|
||||
let sa = sin(-p.angle);
|
||||
let d = frame_delta(uv);
|
||||
// Into the ellipse's own frame, then normalised by its semi-axes so the
|
||||
// problem becomes a unit circle.
|
||||
let local = vec2<f32>(d.x * ca - d.y * sa, d.x * sa + d.y * ca);
|
||||
let r = length(local / max(p.axis, vec2<f32>(1e-6)));
|
||||
|
||||
let edge = clamp(p.softness, 0.0, 1.0);
|
||||
if (edge <= 0.0) {
|
||||
return select(0.0, 1.0, r <= 1.0);
|
||||
}
|
||||
return 1.0 - smoothstep(1.0 - edge, 1.0, r);
|
||||
}
|
||||
|
||||
// Coverage for one object, from its distance field.
|
||||
//
|
||||
// Bilinear on the *distance*, which is the reason this is a distance field at
|
||||
// all: distance varies smoothly across the boundary where coverage does not,
|
||||
// so interpolating it gives a clean sub-pixel edge even though the model's
|
||||
// own mask was quarter-resolution.
|
||||
fn subject_mask(uv: vec2<f32>) -> f32 {
|
||||
let dims = vec2<f32>(textureDimensions(subject));
|
||||
let last = vec2<i32>(dims) - vec2<i32>(1);
|
||||
|
||||
let t = uv * dims - vec2<f32>(0.5);
|
||||
let base = vec2<i32>(floor(t));
|
||||
let f = fract(t);
|
||||
|
||||
let p0 = clamp(base, vec2<i32>(0), last);
|
||||
let p1 = clamp(base + vec2<i32>(1), vec2<i32>(0), last);
|
||||
|
||||
let a = textureLoad(subject, vec2<i32>(p0.x, p0.y), 0).r;
|
||||
let b = textureLoad(subject, vec2<i32>(p1.x, p0.y), 0).r;
|
||||
let c = textureLoad(subject, vec2<i32>(p0.x, p1.y), 0).r;
|
||||
let d = textureLoad(subject, vec2<i32>(p1.x, p1.y), 0).r;
|
||||
|
||||
// `angle` carries the morphology offset in pixels: positive grows the
|
||||
// mask, negative shrinks it. Adding it before the falloff is what makes
|
||||
// dilation move the boundary rather than merely brighten the edge.
|
||||
let dist = mix(mix(a, b, f.x), mix(c, d, f.x), f.y) + p.angle;
|
||||
|
||||
// `softness` is the feather half-width, also in pixels.
|
||||
if (p.softness <= 0.0) {
|
||||
return select(0.0, 1.0, dist >= 0.0);
|
||||
}
|
||||
let t_norm = dist / p.softness;
|
||||
|
||||
// Every curve is 0.5 at the boundary, so changing the falloff changes how
|
||||
// the transition looks and never where it sits.
|
||||
switch p.falloff {
|
||||
case 0u: { return select(0.0, 1.0, dist >= 0.0); }
|
||||
case 1u: { return clamp(t_norm * 0.5 + 0.5, 0.0, 1.0); }
|
||||
case 3u: { return 1.0 / (1.0 + exp(-3.0 * t_norm)); }
|
||||
case 4u: {
|
||||
if (t_norm >= 0.0) {
|
||||
return 1.0 - 0.5 * exp(-3.0 * t_norm);
|
||||
}
|
||||
return 0.5 * exp(3.0 * t_norm);
|
||||
}
|
||||
default: {
|
||||
let x = clamp(t_norm * 0.5 + 0.5, 0.0, 1.0);
|
||||
return x * x * (3.0 - 2.0 * x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let px = vec2<i32>(i32(pos.x), i32(pos.y));
|
||||
|
||||
// Normalised, so a gradient's geometry survives a crop or an export at
|
||||
// another size — the mask is defined on the frame, not on a pixel count.
|
||||
let uv = vec2<f32>(pos.x / f32(p.width), pos.y / f32(p.height));
|
||||
|
||||
var m = 0.0;
|
||||
switch p.mode {
|
||||
case 0u: { m = region_mask(px); }
|
||||
case 1u: { m = linear_mask(uv); }
|
||||
case 2u: { m = radial_mask(uv); }
|
||||
case 3u: { m = subject_mask(uv); }
|
||||
default: { m = 0.0; }
|
||||
}
|
||||
|
||||
return vec4<f32>(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brush strokes (ARCH §5.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The mask the architecture was written for. What arrives is a list of
|
||||
// positions, a radius, a hardness and a flow; what leaves is pixels. Nothing
|
||||
// between the two ever exists in CPU memory, which is the whole difference from
|
||||
// darktable, where the same strokes are rasterised on the CPU and the lag makes
|
||||
// painting unusable.
|
||||
//
|
||||
// # Why the strokes are not drawn by the full-screen triangle above
|
||||
//
|
||||
// Cost. A swept disc is the minimum distance to any segment of its polyline, so
|
||||
// evaluating one stroke costs a distance per segment *per pixel*. Over the
|
||||
// whole frame that is `pixels × segments`, and a stroke that wandered across
|
||||
// the photograph has both terms large at once.
|
||||
//
|
||||
// So each stroke is drawn over its own bounding box instead, expanded by the
|
||||
// radius. The rasteriser then never invokes the fragment shader for a pixel the
|
||||
// stroke cannot reach, and the cost becomes `area(box) × segments` — for the
|
||||
// ordinary case, a dab or a swipe, a small fraction of the frame. The model
|
||||
// splits a long gesture into strokes of bounded length for the same reason:
|
||||
// both terms of that product grow with how far one stroke travelled.
|
||||
//
|
||||
// # Why the strokes composite with fixed-function blending
|
||||
//
|
||||
// Add is `dst + a(1 - dst)` and erase is `dst(1 - a)`, which are exactly a
|
||||
// source-over and a one-minus-source blend. Expressing them as blend state
|
||||
// rather than as arithmetic in the shader is what allows one draw per stroke:
|
||||
// the accumulating mask is the attachment, and no pass ever has to read the
|
||||
// slice it is writing.
|
||||
|
||||
struct StrokeHeader {
|
||||
// Bounding box in normalised coordinates, already grown by the radius and
|
||||
// a texel — the vertex shader trusts it and draws nothing outside it.
|
||||
lo: vec2<f32>,
|
||||
hi: vec2<f32>,
|
||||
// Radius in units of the frame's shorter edge, so a dab is round on a frame
|
||||
// that is not square.
|
||||
radius: f32,
|
||||
// Fraction of the radius that is fully covered.
|
||||
hardness: f32,
|
||||
// Coverage deposited where the stroke is solid.
|
||||
flow: f32,
|
||||
// Window into `stroke_points`.
|
||||
first: u32,
|
||||
count: u32,
|
||||
_pad: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(4) var<storage, read> strokes: array<StrokeHeader>;
|
||||
@group(0) @binding(5) var<storage, read> stroke_points: array<vec2<f32>>;
|
||||
|
||||
struct BrushVertex {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
// Flat: a stroke index interpolated across its own quad would name a
|
||||
// different stroke in the middle of it.
|
||||
@location(0) @interpolate(flat) stroke: u32,
|
||||
}
|
||||
|
||||
// Six vertices per stroke, non-instanced.
|
||||
//
|
||||
// Deliberately not one instance per stroke: `@builtin(instance_index)` with a
|
||||
// non-zero first instance needs base-instance support, which the GL backend
|
||||
// this has to run on under Android cannot promise. Dividing the vertex index
|
||||
// costs one integer operation and works everywhere.
|
||||
@vertex
|
||||
fn vs_brush(@builtin(vertex_index) v: u32) -> BrushVertex {
|
||||
var quad = array<vec2<f32>, 6>(
|
||||
vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
|
||||
vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
|
||||
);
|
||||
|
||||
let i = v / 6u;
|
||||
let s = strokes[i];
|
||||
let uv = mix(s.lo, s.hi, quad[v % 6u]);
|
||||
|
||||
var out: BrushVertex;
|
||||
// y is flipped because normalised mask coordinates run downwards, the way
|
||||
// the fragment shader above reads them, and clip space runs upwards. A
|
||||
// stroke drawn without this lands mirrored about the horizon, which is
|
||||
// plausible enough on a symmetric test image to survive a careless check.
|
||||
out.pos = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
|
||||
out.stroke = i;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Into units of the frame's shorter edge.
|
||||
//
|
||||
// Without this the brush would be a circle in normalised coordinates, which on
|
||||
// a 3:2 frame is an ellipse half again as wide as it is tall. A brush whose dab
|
||||
// is not round is not a brush.
|
||||
fn to_square(uv: vec2<f32>) -> vec2<f32> {
|
||||
let dims = vec2<f32>(f32(p.width), f32(p.height));
|
||||
return uv * dims / min(dims.x, dims.y);
|
||||
}
|
||||
|
||||
fn segment_distance(q: vec2<f32>, a: vec2<f32>, b: vec2<f32>) -> f32 {
|
||||
let ab = b - a;
|
||||
let len2 = dot(ab, ab);
|
||||
// A finger that stopped and went back leaves a zero-length segment, and
|
||||
// dividing by its length is a NaN — which propagates through the min()
|
||||
// below and takes the whole stroke with it.
|
||||
if (len2 <= 1e-12) {
|
||||
return length(q - a);
|
||||
}
|
||||
let t = clamp(dot(q - a, ab) / len2, 0.0, 1.0);
|
||||
return length(q - (a + ab * t));
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_brush(in: BrushVertex) -> @location(0) vec4<f32> {
|
||||
let s = strokes[in.stroke];
|
||||
let q = to_square(vec2<f32>(in.pos.x / f32(p.width), in.pos.y / f32(p.height)));
|
||||
|
||||
// The *minimum* over the segments, which is the maximum of their coverage.
|
||||
// Accumulating the segments instead would make a stroke that crosses itself
|
||||
// — every circle, every scribble — build up a bright patch where it did,
|
||||
// and a soft brush would go blotchy along any curve tight enough for
|
||||
// consecutive dabs to overlap, which is all of them.
|
||||
var d = 1e30;
|
||||
if (s.count == 1u) {
|
||||
// A tap. One point is a legitimate stroke, and it paints one dab.
|
||||
d = length(q - to_square(stroke_points[s.first]));
|
||||
} else {
|
||||
for (var k = 0u; k + 1u < s.count; k = k + 1u) {
|
||||
d = min(
|
||||
d,
|
||||
segment_distance(
|
||||
q,
|
||||
to_square(stroke_points[s.first + k]),
|
||||
to_square(stroke_points[s.first + k + 1u]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Even at full hardness the edge keeps a one-pixel ramp. A true step would
|
||||
// alias into a staircase, and the mask is sampled bilinearly at whatever
|
||||
// zoom the user is inspecting it at — which is where an edge is judged.
|
||||
let texel = 1.0 / f32(min(p.width, p.height));
|
||||
let inner = min(s.radius * clamp(s.hardness, 0.0, 1.0), max(s.radius - texel, 0.0));
|
||||
let coverage = 1.0 - smoothstep(inner, s.radius, d);
|
||||
|
||||
return vec4<f32>(clamp(coverage * s.flow, 0.0, 1.0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// Watershed segmentation — the passes behind arm A of S15 (docs/segmentation.md).
|
||||
//
|
||||
// Seven entry points forming one chain:
|
||||
//
|
||||
// features source texture -> perceptual triple, downscaled to proxy size
|
||||
// blur pre-smoothing, without which every grain becomes a basin
|
||||
// gradient Sobel magnitude — the surface the watershed floods
|
||||
// plateau_init seed the distance field at every real descent
|
||||
// plateau_step carry it inward, so flat ground drains toward its exit
|
||||
// flow each pixel points downhill to its steepest neighbour
|
||||
// jump pointer-jumping, until every pixel points at its basin root
|
||||
//
|
||||
// Everything after `features` works in storage buffers rather than textures.
|
||||
// That is deliberate: the flow and jump passes need read-write access to the
|
||||
// same array across dispatches, which storage textures do not give portably,
|
||||
// and a buffer reads back without the 256-byte row padding a texture copy
|
||||
// imposes.
|
||||
|
||||
struct Params {
|
||||
// Proxy dimensions — what every pass but `features` iterates over.
|
||||
width: u32,
|
||||
height: u32,
|
||||
// Source dimensions, for the box downscale in `features`.
|
||||
src_width: u32,
|
||||
src_height: u32,
|
||||
// Half-width of the pre-smoothing kernel, in proxy pixels. 0 disables it.
|
||||
blur_radius: i32,
|
||||
// 1 when the source is already display-encoded (the JPEG path), 0 for
|
||||
// linear scene-referred data out of the demosaicer.
|
||||
non_linear: u32,
|
||||
// How much luma and chroma each contribute to the gradient. Chroma is
|
||||
// weighted lower because it carries most of the sensor noise and few of
|
||||
// the boundaries a person would draw.
|
||||
w_luma: f32,
|
||||
w_chroma: f32,
|
||||
}
|
||||
|
||||
// Binding slots are unique across the whole module, not reused per entry
|
||||
// point: WGSL resource variables share one namespace, so two globals at the
|
||||
// same (group, binding) is a module-level validation error even when no
|
||||
// single entry point uses both. Each pass therefore gets its own pair, and
|
||||
// each pipeline a layout declaring only the slots it touches.
|
||||
@group(0) @binding(0) var<uniform> u: Params;
|
||||
|
||||
// ---------------------------------------------------------------- features
|
||||
|
||||
@group(0) @binding(1) var src: texture_2d<f32>;
|
||||
@group(0) @binding(2) var<storage, read_write> feat_out: array<vec4<f32>>;
|
||||
|
||||
// Linear or display-encoded RGB to a roughly perceptual opponent triple.
|
||||
//
|
||||
// Perceptual rather than linear because the gradient has to agree with what
|
||||
// a person calls an edge. In linear light a highlight rolloff swamps the
|
||||
// boundary between two midtones, and the watershed would put its strongest
|
||||
// walls where nobody sees one.
|
||||
//
|
||||
// The two chroma axes are opponent differences rather than a real Lab
|
||||
// transform: they cost three subtractions instead of a matrix and a cube
|
||||
// root, and the watershed only needs the *magnitude* of colour change, not a
|
||||
// colorimetrically defensible value for it.
|
||||
fn perceptual(c_in: vec3<f32>) -> vec3<f32> {
|
||||
var c = max(c_in, vec3<f32>(0.0));
|
||||
if (u.non_linear == 0u) {
|
||||
c = pow(c, vec3<f32>(1.0 / 2.4));
|
||||
}
|
||||
let l = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
let a = c.r - c.g;
|
||||
let b = c.b - 0.5 * (c.r + c.g);
|
||||
return vec3<f32>(l, a, b);
|
||||
}
|
||||
|
||||
// Source -> proxy, averaging every source pixel that falls in the proxy
|
||||
// pixel's footprint.
|
||||
//
|
||||
// A box average rather than point sampling because the proxy is where the
|
||||
// segmentation happens: point sampling a 24 MP sensor down to 2 MP aliases
|
||||
// fine texture into false gradient, and the watershed would faithfully find
|
||||
// basins in the aliasing.
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn features(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= u.width || gid.y >= u.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
let sx0 = (gid.x * u.src_width) / u.width;
|
||||
let sy0 = (gid.y * u.src_height) / u.height;
|
||||
let sx1 = max(sx0 + 1u, ((gid.x + 1u) * u.src_width) / u.width);
|
||||
let sy1 = max(sy0 + 1u, ((gid.y + 1u) * u.src_height) / u.height);
|
||||
|
||||
var acc = vec3<f32>(0.0);
|
||||
var n = 0.0;
|
||||
for (var sy = sy0; sy < sy1; sy = sy + 1u) {
|
||||
for (var sx = sx0; sx < sx1; sx = sx + 1u) {
|
||||
let c = textureLoad(src, vec2<i32>(i32(sx), i32(sy)), 0).rgb;
|
||||
acc = acc + perceptual(c);
|
||||
n = n + 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
feat_out[gid.y * u.width + gid.x] = vec4<f32>(acc / max(n, 1.0), 0.0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- blur
|
||||
|
||||
@group(0) @binding(3) var<storage, read> blur_in: array<vec4<f32>>;
|
||||
@group(0) @binding(4) var<storage, read_write> blur_out: array<vec4<f32>>;
|
||||
|
||||
fn clamp_coord(v: i32, hi: u32) -> u32 {
|
||||
return u32(clamp(v, 0, i32(hi) - 1));
|
||||
}
|
||||
|
||||
// Pre-smoothing. Not a refinement — without it the watershed is unusable.
|
||||
//
|
||||
// A raw gradient over sensor data has a local minimum at every noise grain,
|
||||
// and one basin per local minimum means a 2 MP frame segments into hundreds
|
||||
// of thousands of regions that correspond to nothing. The radius is the
|
||||
// caller's to set from ISO.
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn blur(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= u.width || gid.y >= u.height) {
|
||||
return;
|
||||
}
|
||||
let idx = gid.y * u.width + gid.x;
|
||||
|
||||
if (u.blur_radius <= 0) {
|
||||
blur_out[idx] = blur_in[idx];
|
||||
return;
|
||||
}
|
||||
|
||||
var acc = vec3<f32>(0.0);
|
||||
var wsum = 0.0;
|
||||
let r = u.blur_radius;
|
||||
for (var dy = -r; dy <= r; dy = dy + 1) {
|
||||
for (var dx = -r; dx <= r; dx = dx + 1) {
|
||||
let sx = clamp_coord(i32(gid.x) + dx, u.width);
|
||||
let sy = clamp_coord(i32(gid.y) + dy, u.height);
|
||||
let d2 = f32(dx * dx + dy * dy);
|
||||
let w = exp(-d2 / (2.0 * f32(r) * f32(r)));
|
||||
acc = acc + blur_in[sy * u.width + sx].rgb * w;
|
||||
wsum = wsum + w;
|
||||
}
|
||||
}
|
||||
blur_out[idx] = vec4<f32>(acc / wsum, 0.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- gradient
|
||||
|
||||
@group(0) @binding(5) var<storage, read> grad_in: array<vec4<f32>>;
|
||||
@group(0) @binding(6) var<storage, read_write> grad_out: array<f32>;
|
||||
|
||||
fn feat_at(x: i32, y: i32) -> vec3<f32> {
|
||||
let sx = clamp_coord(x, u.width);
|
||||
let sy = clamp_coord(y, u.height);
|
||||
return grad_in[sy * u.width + sx].rgb;
|
||||
}
|
||||
|
||||
// Sobel magnitude over the weighted opponent triple.
|
||||
//
|
||||
// This is the surface the watershed floods, so its units matter for nothing
|
||||
// except ordering — only the *relative* height of one boundary against
|
||||
// another decides which regions merge first.
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn gradient(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= u.width || gid.y >= u.height) {
|
||||
return;
|
||||
}
|
||||
let x = i32(gid.x);
|
||||
let y = i32(gid.y);
|
||||
|
||||
let tl = feat_at(x - 1, y - 1);
|
||||
let tc = feat_at(x, y - 1);
|
||||
let tr = feat_at(x + 1, y - 1);
|
||||
let ml = feat_at(x - 1, y);
|
||||
let mr = feat_at(x + 1, y);
|
||||
let bl = feat_at(x - 1, y + 1);
|
||||
let bc = feat_at(x, y + 1);
|
||||
let br = feat_at(x + 1, y + 1);
|
||||
|
||||
let gx = (tr + 2.0 * mr + br) - (tl + 2.0 * ml + bl);
|
||||
let gy = (bl + 2.0 * bc + br) - (tl + 2.0 * tc + tr);
|
||||
|
||||
let w = vec3<f32>(u.w_luma, u.w_chroma, u.w_chroma);
|
||||
let wx = gx * w;
|
||||
let wy = gy * w;
|
||||
|
||||
grad_out[gid.y * u.width + gid.x] = sqrt(dot(wx, wx) + dot(wy, wy));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- lower-complete
|
||||
//
|
||||
// A watershed needs every non-minimum pixel to have a lower neighbour. A real
|
||||
// gradient does not oblige: a flat wall, a clipped sky or the inside of a
|
||||
// uniform object is a **plateau**, where every neighbour is exactly equal and
|
||||
// there is no downhill direction to follow.
|
||||
//
|
||||
// Left alone, the tie-break in `flow` sends every plateau pixel to its
|
||||
// lowest-indexed neighbour, which is up and to the left. Each pixel therefore
|
||||
// walks diagonally until it falls off the plateau, and one flat region becomes
|
||||
// a fan of diagonal chains rather than one basin — visible as hatching across
|
||||
// what should be a single area (docs/segmentation.md §12, F1).
|
||||
//
|
||||
// The fix is the standard lower-completion: give each plateau pixel its
|
||||
// geodesic distance to the nearest pixel that *does* have a lower neighbour,
|
||||
// then let `flow` order on (gradient, distance). Water on a plateau now runs
|
||||
// toward the plateau's exit, which is what it would physically do.
|
||||
//
|
||||
// A plateau with no exit at all is a genuine regional minimum — the inside of
|
||||
// a uniform disc, say. Those pixels keep `PLATEAU_UNRESOLVED`, tie with each
|
||||
// other, and fall through to the index tie-break, which collapses the whole
|
||||
// connected plateau onto its lowest-indexed pixel. One basin, which is the
|
||||
// right answer for a regional minimum.
|
||||
|
||||
const PLATEAU_UNRESOLVED: u32 = 0xffffffffu;
|
||||
|
||||
// How close two gradients must be to count as the same level.
|
||||
//
|
||||
// **Exact equality does not work here, and that is not a rounding nicety.**
|
||||
// The gradient is a float computed from 8-bit samples, so a region the eye
|
||||
// and the algorithm both consider flat still has neighbours differing in the
|
||||
// sixth decimal. With `==`, the breadth-first step never advances past its
|
||||
// seeds and the whole pass is a no-op; with `<`, nearly every pixel finds
|
||||
// some marginally lower neighbour and is seeded at zero, which is the same
|
||||
// no-op wearing a different hat. Both were measured before this constant
|
||||
// existed.
|
||||
//
|
||||
// Sized against the gradient's own scale: features are normalised to 0..1, so
|
||||
// a Sobel magnitude runs to a few units, and 1e-4 is far below any step a
|
||||
// real edge produces while sitting comfortably above f32 noise from a blur.
|
||||
const LEVEL_EPS: f32 = 1e-4;
|
||||
|
||||
// Whether `b` lies below `a` by more than the level tolerance.
|
||||
fn strictly_below(b: f32, a: f32) -> bool {
|
||||
return b < a - LEVEL_EPS;
|
||||
}
|
||||
|
||||
// Whether two gradients belong to the same plateau.
|
||||
fn same_level(a: f32, b: f32) -> bool {
|
||||
return abs(a - b) <= LEVEL_EPS;
|
||||
}
|
||||
|
||||
@group(0) @binding(7) var<storage, read> pinit_grad: array<f32>;
|
||||
@group(0) @binding(8) var<storage, read_write> pinit_out: array<u32>;
|
||||
|
||||
// Seed the distance field: zero where a real descent exists, unresolved on a
|
||||
// plateau.
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn plateau_init(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= u.width || gid.y >= u.height) {
|
||||
return;
|
||||
}
|
||||
let idx = gid.y * u.width + gid.x;
|
||||
let here = pinit_grad[idx];
|
||||
|
||||
for (var dy = -1; dy <= 1; dy = dy + 1) {
|
||||
for (var dx = -1; dx <= 1; dx = dx + 1) {
|
||||
if (dx == 0 && dy == 0) {
|
||||
continue;
|
||||
}
|
||||
let nx = i32(gid.x) + dx;
|
||||
let ny = i32(gid.y) + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= i32(u.width) || ny >= i32(u.height)) {
|
||||
continue;
|
||||
}
|
||||
if (strictly_below(pinit_grad[u32(ny) * u.width + u32(nx)], here)) {
|
||||
pinit_out[idx] = 0u;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pinit_out[idx] = PLATEAU_UNRESOLVED;
|
||||
}
|
||||
|
||||
@group(0) @binding(9) var<storage, read> pstep_grad: array<f32>;
|
||||
@group(0) @binding(10) var<storage, read> pstep_in: array<u32>;
|
||||
@group(0) @binding(11) var<storage, read_write> pstep_out: array<u32>;
|
||||
|
||||
// One breadth-first step inward from the plateau's rim.
|
||||
//
|
||||
// Iterated by the host a fixed number of times rather than to convergence: a
|
||||
// convergence test costs a readback per pass, and the count only has to cover
|
||||
// the widest plateau in the frame. Pixels still unresolved when the budget
|
||||
// runs out keep `PLATEAU_UNRESOLVED` and behave exactly as they did before
|
||||
// this pass existed — the degradation is graceful, not a wrong answer.
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn plateau_step(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= u.width || gid.y >= u.height) {
|
||||
return;
|
||||
}
|
||||
let idx = gid.y * u.width + gid.x;
|
||||
let current = pstep_in[idx];
|
||||
if (current != PLATEAU_UNRESOLVED) {
|
||||
pstep_out[idx] = current;
|
||||
return;
|
||||
}
|
||||
|
||||
let here = pstep_grad[idx];
|
||||
var best = PLATEAU_UNRESOLVED;
|
||||
for (var dy = -1; dy <= 1; dy = dy + 1) {
|
||||
for (var dx = -1; dx <= 1; dx = dx + 1) {
|
||||
if (dx == 0 && dy == 0) {
|
||||
continue;
|
||||
}
|
||||
let nx = i32(gid.x) + dx;
|
||||
let ny = i32(gid.y) + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= i32(u.width) || ny >= i32(u.height)) {
|
||||
continue;
|
||||
}
|
||||
let ni = u32(ny) * u.width + u32(nx);
|
||||
// Only within the same plateau: a neighbour at a different height
|
||||
// is across a boundary, and its distance says nothing about the
|
||||
// way out of this one.
|
||||
if (!same_level(pstep_grad[ni], here)) {
|
||||
continue;
|
||||
}
|
||||
let nd = pstep_in[ni];
|
||||
if (nd != PLATEAU_UNRESOLVED && nd < best) {
|
||||
best = nd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (best == PLATEAU_UNRESOLVED) {
|
||||
pstep_out[idx] = PLATEAU_UNRESOLVED;
|
||||
} else {
|
||||
pstep_out[idx] = best + 1u;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- flow
|
||||
|
||||
@group(0) @binding(12) var<storage, read> flow_grad: array<f32>;
|
||||
@group(0) @binding(13) var<storage, read> flow_dist: array<u32>;
|
||||
@group(0) @binding(14) var<storage, read_write> flow_out: array<u32>;
|
||||
|
||||
// Each pixel points at the steepest-descent neighbour among its 8, or at
|
||||
// itself if it is a local minimum — a basin seed.
|
||||
//
|
||||
// **The ordering is load-bearing, twice over.** Comparing on (gradient,
|
||||
// plateau distance, index) rather than gradient alone gives a strict total
|
||||
// order, so the pointer graph descends monotonically and cannot contain a
|
||||
// cycle — plateaux, which are everywhere in a smoothed image, would otherwise
|
||||
// make two equal pixels point at each other and hang the pointer-jumping
|
||||
// below.
|
||||
//
|
||||
// It is also what makes the result reproducible. S15's M5 asks whether a
|
||||
// label field is stable enough across GPU vendors to be a cache key
|
||||
// (ARCH §6.13); an arbitrary tie-break would answer no before the question
|
||||
// was asked.
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn flow(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= u.width || gid.y >= u.height) {
|
||||
return;
|
||||
}
|
||||
let idx = gid.y * u.width + gid.x;
|
||||
|
||||
var best_val = flow_grad[idx];
|
||||
var best_dist = flow_dist[idx];
|
||||
var best_idx = idx;
|
||||
|
||||
for (var dy = -1; dy <= 1; dy = dy + 1) {
|
||||
for (var dx = -1; dx <= 1; dx = dx + 1) {
|
||||
if (dx == 0 && dy == 0) {
|
||||
continue;
|
||||
}
|
||||
let nx = i32(gid.x) + dx;
|
||||
let ny = i32(gid.y) + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= i32(u.width) || ny >= i32(u.height)) {
|
||||
continue;
|
||||
}
|
||||
let ni = u32(ny) * u.width + u32(nx);
|
||||
let nv = flow_grad[ni];
|
||||
let nd = flow_dist[ni];
|
||||
|
||||
// Lexicographic on (gradient, plateau distance, index) rather
|
||||
// than one fused scalar. Folding the distance into the gradient
|
||||
// as a small epsilon would need a scale factor that is small
|
||||
// enough never to cross a real gradient step and large enough to
|
||||
// survive f32 — a tuning problem with a silent failure mode,
|
||||
// where three explicit keys have neither.
|
||||
// The same tolerance the plateau passes use, and for the same
|
||||
// reason: with exact equality this tie never fires on real data,
|
||||
// so the distance carried inward above would be computed and then
|
||||
// never consulted — the pass measurably did nothing.
|
||||
var better = false;
|
||||
if (strictly_below(nv, best_val)) {
|
||||
better = true;
|
||||
} else if (same_level(nv, best_val)) {
|
||||
if (nd < best_dist) {
|
||||
better = true;
|
||||
} else if (nd == best_dist && ni < best_idx) {
|
||||
better = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (better) {
|
||||
best_val = nv;
|
||||
best_dist = nd;
|
||||
best_idx = ni;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flow_out[idx] = best_idx;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- jump
|
||||
|
||||
@group(0) @binding(15) var<storage, read> jump_in: array<u32>;
|
||||
@group(0) @binding(16) var<storage, read_write> jump_out: array<u32>;
|
||||
|
||||
// Pointer jumping: parent = parent[parent].
|
||||
//
|
||||
// Halves every path length per dispatch, so ceil(log2(longest path)) passes
|
||||
// resolve every pixel to its basin root. The host runs a fixed count bounded
|
||||
// by log2(pixel count) rather than testing for convergence, because a
|
||||
// convergence test costs a readback per iteration and the bound is ~21
|
||||
// dispatches of a trivial kernel.
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn jump(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= u.width || gid.y >= u.height) {
|
||||
return;
|
||||
}
|
||||
let idx = gid.y * u.width + gid.x;
|
||||
jump_out[idx] = jump_in[jump_in[idx]];
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// Black/white normalisation and X-Trans demosaic, in one pass.
|
||||
//
|
||||
// The Fujifilm counterpart to demosaic.wgsl (FR-RAW-5). The input and the
|
||||
// output are the same — packed u16 photosites in, linear camera-space
|
||||
// RGBA16Float out — but the colour filter array is a 6x6 tile rather than a
|
||||
// 2x2 one, and none of the Bayer kernels survive that. In a Bayer cell every
|
||||
// pixel has the missing channels at a fixed offset; in X-Trans the offsets
|
||||
// differ at all 36 positions, so a fixed kernel per site would need 36 of
|
||||
// them and would still say nothing about which neighbours to trust.
|
||||
//
|
||||
// The method here is *local plane fitting on the colour difference*:
|
||||
//
|
||||
// 1. Take a 5x5 window and sort its photosites by the channel each one
|
||||
// measures. Every window holds at least four red, four blue and thirteen
|
||||
// green samples, whatever the phase — checked exhaustively, not assumed.
|
||||
// 2. Fit a weighted least-squares plane through each channel's samples and
|
||||
// evaluate all three planes at the pixel. A plane rather than a mean
|
||||
// because the three channels are sampled at *different* places: a mean
|
||||
// would compare a red average taken slightly left of the pixel with a
|
||||
// green average taken slightly right of it, and the difference of those
|
||||
// two offsets is a colour cast that follows every gradient in the frame.
|
||||
// A plane has no such bias — it reconstructs any linear gradient exactly.
|
||||
// 3. Keep the pixel's own measured value, and carry the other two channels
|
||||
// across as the *difference* between the fitted planes.
|
||||
//
|
||||
// Step 3 is the standard constant-colour-difference model, and the reason it
|
||||
// is applied in white-balanced space is that the model is exact only where
|
||||
// the channel difference is locally constant. On a neutral subject that is
|
||||
// true after white balance and false before it, so the gains go on before the
|
||||
// fit and come off after — which costs one multiply and halves the error at a
|
||||
// luminance edge (measured on a synthetic step: 0.34 -> 0.20 max error).
|
||||
// The pixels written out are still as-shot, unbalanced camera space; nothing
|
||||
// downstream sees the difference.
|
||||
//
|
||||
// **What this is not.** It is not Markesteijn. It has no directional
|
||||
// hypotheses and no homogeneity map, so it does not resolve detail finer than
|
||||
// the CFA period, and a hard edge arrives about two pixels wide. It does not
|
||||
// produce the "worms" that FR-RAW-5 exists to avoid — the output is bounded
|
||||
// by the local sample range, so it cannot ring — but the Markesteijn-class
|
||||
// quality that requirement asks for is still owed.
|
||||
|
||||
struct XTransParams {
|
||||
// Dimensions of the *cropped* output, in pixels.
|
||||
width: u32,
|
||||
height: u32,
|
||||
// Origin of the crop within the sensor readout, in photosites. Added to
|
||||
// every read, and to every pattern lookup: the 6x6 tile is anchored to the
|
||||
// sensor, not to the visible frame.
|
||||
crop_x: u32,
|
||||
crop_y: u32,
|
||||
// Row stride of the input, in samples.
|
||||
stride: u32,
|
||||
// One black level and one reciprocal range for the whole sensor. The
|
||||
// four-value form the Bayer path uses is a 2x2 convention with no meaning
|
||||
// on a 6x6 tile.
|
||||
black: f32,
|
||||
inv_range: f32,
|
||||
_pad0: u32,
|
||||
// As-shot white balance gains, green-normalised, and their reciprocals.
|
||||
wb: vec4<f32>,
|
||||
inv_wb: vec4<f32>,
|
||||
// The 6x6 tile, already rotated to this sensor's phase on the CPU, packed
|
||||
// two bits per photosite: word k holds row 2k in its low 12 bits and row
|
||||
// 2k+1 in the next 12. The fourth word is padding.
|
||||
tile: vec4<u32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<storage, read> raw: array<u32>;
|
||||
@group(0) @binding(1) var<uniform> params: XTransParams;
|
||||
@group(0) @binding(2) var output: texture_storage_2d<rgba16float, write>;
|
||||
|
||||
// Half-width of the fitting window. Two is the smallest radius for which
|
||||
// every phase of the tile still offers enough red and blue samples to pin a
|
||||
// plane down; three would be smoother and blurrier.
|
||||
const RADIUS: i32 = 2;
|
||||
|
||||
// Colour of the photosite at absolute sensor coordinates: 0=R, 1=G, 2=B.
|
||||
fn colour_at(sx: u32, sy: u32) -> u32 {
|
||||
let row = sy % 6u;
|
||||
let col = sx % 6u;
|
||||
let word = params.tile[row >> 1u];
|
||||
return (word >> ((row & 1u) * 12u + col * 2u)) & 3u;
|
||||
}
|
||||
|
||||
// Read one photosite, normalised to [0, 1] against the black level.
|
||||
//
|
||||
// Coordinates are relative to the crop origin and must already be in range;
|
||||
// unlike the Bayer pass there is no reflection here, because the window is
|
||||
// slid inside the image instead (see `main`) and so never asks for a
|
||||
// photosite that does not exist.
|
||||
fn sample(cx: i32, cy: i32) -> f32 {
|
||||
let sx = u32(cx) + params.crop_x;
|
||||
let sy = u32(cy) + params.crop_y;
|
||||
let index = sy * params.stride + sx;
|
||||
|
||||
let word = raw[index >> 1u];
|
||||
let raw_value = select(word & 0xFFFFu, word >> 16u, (index & 1u) == 1u);
|
||||
|
||||
// Sensor noise puts real signal below the black point, so subtracting it
|
||||
// can go negative; clamped rather than allowed to wrap.
|
||||
return max((f32(raw_value) - params.black) * params.inv_range, 0.0);
|
||||
}
|
||||
|
||||
// Solve the 3x3 weighted least-squares normal equations for a plane
|
||||
// `c0 + c1*dx + c2*dy` and evaluate it at `(ex, ey)`.
|
||||
//
|
||||
// The accumulators are the usual moments: `n` is the summed weight, `sx`..`syy`
|
||||
// the first and second moments of the sample positions, `t0`..`ty` the same
|
||||
// moments weighted by value. Cramer's rule rather than a factorisation — the
|
||||
// matrix is 3x3 and symmetric, and this keeps the whole solve in registers.
|
||||
fn plane_at(
|
||||
n: f32, sx: f32, sy: f32, sxx: f32, sxy: f32, syy: f32,
|
||||
t0: f32, tx: f32, ty: f32, ex: f32, ey: f32,
|
||||
) -> f32 {
|
||||
if (n <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
let det = n * (sxx * syy - sxy * sxy)
|
||||
- sx * (sx * syy - sxy * sy)
|
||||
+ sy * (sx * sxy - sxx * sy);
|
||||
|
||||
// Degenerate only if a channel's samples in this window are collinear,
|
||||
// which the 5x5 geometry rules out for every phase — but an image a few
|
||||
// photosites across is clipped down to fewer samples than that, and a
|
||||
// division by a near-zero determinant there would put NaN in the texture.
|
||||
// Falling back to the plain weighted mean loses the gradient term and
|
||||
// nothing else.
|
||||
if (abs(det) < 1e-6 * n * n * n) {
|
||||
return t0 / n;
|
||||
}
|
||||
|
||||
let inv = 1.0 / det;
|
||||
let c0 = (t0 * (sxx * syy - sxy * sxy)
|
||||
- sx * (tx * syy - sxy * ty)
|
||||
+ sy * (tx * sxy - sxx * ty)) * inv;
|
||||
let c1 = (n * (tx * syy - sxy * ty)
|
||||
- t0 * (sx * syy - sxy * sy)
|
||||
+ sy * (sx * ty - tx * sy)) * inv;
|
||||
let c2 = (n * (sxx * ty - tx * sxy)
|
||||
- sx * (sx * ty - tx * sy)
|
||||
+ t0 * (sx * sxy - sxx * sy)) * inv;
|
||||
return c0 + c1 * ex + c2 * ey;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= params.width || gid.y >= params.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
let x = i32(gid.x);
|
||||
let y = i32(gid.y);
|
||||
let w = i32(params.width);
|
||||
let h = i32(params.height);
|
||||
|
||||
// Near an edge the window slides inward rather than reflecting. Reflection
|
||||
// is right for the Bayer pass, whose kernels only need the mirrored sample
|
||||
// to be the same colour; here the fit needs real geometry, and a mirrored
|
||||
// photosite sitting at a position it does not occupy tilts the plane. A
|
||||
// slid window is entirely real data, so the plane stays exact right up to
|
||||
// the border — the pixel is still inside the window, just not centred in
|
||||
// it, which is what `ex`/`ey` below account for.
|
||||
let wx = clamp(x, RADIUS, max(RADIUS, w - 1 - RADIUS));
|
||||
let wy = clamp(y, RADIUS, max(RADIUS, h - 1 - RADIUS));
|
||||
|
||||
// Per-channel moments, indexed 0=R, 1=G, 2=B.
|
||||
var n = vec3<f32>(0.0);
|
||||
var sx = vec3<f32>(0.0);
|
||||
var sy = vec3<f32>(0.0);
|
||||
var sxx = vec3<f32>(0.0);
|
||||
var sxy = vec3<f32>(0.0);
|
||||
var syy = vec3<f32>(0.0);
|
||||
var t0 = vec3<f32>(0.0);
|
||||
var tx = vec3<f32>(0.0);
|
||||
var ty = vec3<f32>(0.0);
|
||||
var lo = vec3<f32>(1.0e30);
|
||||
var hi = vec3<f32>(-1.0e30);
|
||||
|
||||
for (var dy = -RADIUS; dy <= RADIUS; dy = dy + 1) {
|
||||
for (var dx = -RADIUS; dx <= RADIUS; dx = dx + 1) {
|
||||
// The clamp only bites on an image narrower than the window, where
|
||||
// a duplicated photosite is better than a missing channel.
|
||||
let px = clamp(wx + dx, 0, w - 1);
|
||||
let py = clamp(wy + dy, 0, h - 1);
|
||||
|
||||
let v = sample(px, py);
|
||||
let k = colour_at(u32(px) + params.crop_x, u32(py) + params.crop_y);
|
||||
let u = v * params.wb[k];
|
||||
|
||||
let fx = f32(px - wx);
|
||||
let fy = f32(py - wy);
|
||||
// Nearer photosites describe this pixel better. 1/(1+r^2) rather
|
||||
// than a Gaussian because it needs no width to tune and leaves the
|
||||
// normal equations well conditioned at every phase.
|
||||
let g = 1.0 / (1.0 + fx * fx + fy * fy);
|
||||
|
||||
n[k] = n[k] + g;
|
||||
sx[k] = sx[k] + g * fx;
|
||||
sy[k] = sy[k] + g * fy;
|
||||
sxx[k] = sxx[k] + g * fx * fx;
|
||||
sxy[k] = sxy[k] + g * fx * fy;
|
||||
syy[k] = syy[k] + g * fy * fy;
|
||||
t0[k] = t0[k] + g * u;
|
||||
tx[k] = tx[k] + g * u * fx;
|
||||
ty[k] = ty[k] + g * u * fy;
|
||||
|
||||
lo[k] = min(lo[k], v);
|
||||
hi[k] = max(hi[k], v);
|
||||
}
|
||||
}
|
||||
|
||||
let ex = f32(x - wx);
|
||||
let ey = f32(y - wy);
|
||||
var p = vec3<f32>(
|
||||
plane_at(n.r, sx.r, sy.r, sxx.r, sxy.r, syy.r, t0.r, tx.r, ty.r, ex, ey),
|
||||
plane_at(n.g, sx.g, sy.g, sxx.g, sxy.g, syy.g, t0.g, tx.g, ty.g, ex, ey),
|
||||
plane_at(n.b, sx.b, sy.b, sxx.b, sxy.b, syy.b, t0.b, tx.b, ty.b, ex, ey),
|
||||
);
|
||||
|
||||
// The pixel's own channel always has a sample — itself — so its plane is
|
||||
// always real, and it is the reference the other two are carried across
|
||||
// from.
|
||||
let centre = colour_at(u32(x) + params.crop_x, u32(y) + params.crop_y);
|
||||
let measured = sample(x, y);
|
||||
|
||||
// A channel with no sample at all cannot happen in a 5x5 window; it can on
|
||||
// an image a few photosites across, where the window collapses. Such a
|
||||
// channel is given the reference plane, which renders the pixel grey
|
||||
// rather than arbitrary.
|
||||
let empty = n <= vec3<f32>(0.0);
|
||||
p = select(p, vec3<f32>(p[centre]), empty);
|
||||
lo = select(lo, vec3<f32>(0.0), empty);
|
||||
hi = select(hi, vec3<f32>(1.0e30), empty);
|
||||
|
||||
// Constant colour difference, in white-balanced space, undone on the way
|
||||
// out. The measured channel comes back bit-for-bit: its own plane cancels.
|
||||
var rgb = (vec3<f32>(measured * params.wb[centre]) + p - vec3<f32>(p[centre]))
|
||||
* params.inv_wb.rgb;
|
||||
|
||||
// Bound each channel by what was actually measured nearby. The plane
|
||||
// difference overshoots wherever the colour itself changes across the
|
||||
// window — a red edge against green — and an overshoot here is a coloured
|
||||
// halo. The pixel is always inside its own window, so a true value can
|
||||
// never be clipped away by this on smooth content.
|
||||
rgb = clamp(rgb, lo, hi);
|
||||
rgb = max(rgb, vec3<f32>(0.0));
|
||||
|
||||
textureStore(output, vec2<i32>(x, y), vec4<f32>(rgb, 1.0));
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! TRACES: FR-DEV-3e
|
||||
//! The camera profile's base curve, end to end on a device.
|
||||
//!
|
||||
//! The unit tests either side of this one check halves. `dr-decode` asserts
|
||||
//! that the shipped database parses and that every curve in it lifts its
|
||||
//! midtones; `dr-pipeline` asserts that the generated WGSL evaluates a curve
|
||||
//! in the right place. Neither would notice if the two agreed with each other
|
||||
//! and both were wrong — a curve packed into the wrong uniform slots, or a
|
||||
//! flag read from the wrong component, satisfies both and renders nothing.
|
||||
//!
|
||||
//! So this renders real pixels twice, once with a profiled body's curve and
|
||||
//! once with the identity, and asserts the difference is the one a base curve
|
||||
//! is for: midtones lifted, black still black, white still white.
|
||||
|
||||
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
const SIZE: u32 = 16;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A flat RGGB frame at `level` out of 65535, carrying `curve`.
|
||||
///
|
||||
/// Every photosite the same value, so the demosaic result is a uniform grey
|
||||
/// and the only thing that can move a pixel is the curve. The colour matrix is
|
||||
/// the identity and the balance is neutral for the same reason: this test is
|
||||
/// about one stage, and a real body's matrix would make every assertion below
|
||||
/// a statement about that body instead.
|
||||
fn flat_raw(level: u16, curve: BaseCurve) -> RawImage {
|
||||
RawImage {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
data: vec![level; (SIZE * SIZE) as usize],
|
||||
cfa_pattern: CfaPattern::Rggb,
|
||||
black_level: [0; 4],
|
||||
white_level: u16::MAX,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
base_curve: curve,
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a neutral edit over a flat frame and return the centre pixel's red.
|
||||
///
|
||||
/// The centre rather than a corner: a demosaic has to invent its edges, and
|
||||
/// the interpolated border of a 16×16 frame is not where anyone should be
|
||||
/// reading a tone off.
|
||||
fn rendered_level(ctx: &GpuContext, level: u16, curve: BaseCurve) -> u8 {
|
||||
let raw = flat_raw(level, curve);
|
||||
let source = Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&raw)
|
||||
.expect("demosaic");
|
||||
let shader = EditGraph::default_chain().compose();
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render(&source, &shader, SIZE, SIZE)
|
||||
.expect("render");
|
||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
||||
pixels[centre as usize]
|
||||
}
|
||||
|
||||
/// The Canon EOS 6D's curve, from the shipped profile database.
|
||||
///
|
||||
/// Looked up by name rather than written out, so this also asserts the thing
|
||||
/// no other test can: that a curve travels from the YAML, through the body
|
||||
/// match, onto the decoded image and into the uniform block that the shader
|
||||
/// actually reads.
|
||||
fn six_d() -> BaseCurve {
|
||||
let curve = dr_decode::base_curve::for_body("Canon", "EOS 6D");
|
||||
assert!(
|
||||
!curve.is_identity(),
|
||||
"the shipped database must have a curve for the EOS 6D"
|
||||
);
|
||||
curve
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profiled_body_renders_brighter_midtones_than_a_flat_one() {
|
||||
// **The whole requirement, in one assertion.** A linear midtone renders
|
||||
// roughly half a stop dark, which is the flat, lifeless look FR-DEV-3e
|
||||
// exists to get away from. If the curve did not reach the shader — wrong
|
||||
// slot, wrong flag, wrong stage — this is the only test that would fail.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
|
||||
// 13% of full scale: roughly where a camera places middle grey, leaving
|
||||
// about two and a half stops of highlight headroom above it.
|
||||
let level = (0.13 * 65535.0) as u16;
|
||||
let flat = rendered_level(&ctx, level, BaseCurve::IDENTITY);
|
||||
let profiled = rendered_level(&ctx, level, six_d());
|
||||
|
||||
assert!(
|
||||
profiled > flat + 8,
|
||||
"the profile lifted middle grey from {flat} only to {profiled}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_leaves_black_black_and_white_white() {
|
||||
// A base curve renders the range between the endpoints; it must not move
|
||||
// the endpoints themselves. A curve that lifted black would put a grey
|
||||
// veil over every night photograph, and one that pulled white down would
|
||||
// make a correctly exposed frame look underexposed.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
|
||||
let curve = six_d();
|
||||
assert_eq!(rendered_level(&ctx, 0, curve), 0, "black moved");
|
||||
assert_eq!(rendered_level(&ctx, u16::MAX, curve), 255, "white moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unprofiled_body_renders_exactly_as_it_did_before_profiles_existed() {
|
||||
// The graceful fallback, asserted as a number rather than as a promise.
|
||||
// With no curve the pipeline must still be a pass-through: black level
|
||||
// out, white level in, sRGB encoding on the way to the screen and nothing
|
||||
// else. "Never worse than today" is the one property this change was not
|
||||
// allowed to trade away, and the way it would break is silently — a flag
|
||||
// read from the wrong component would apply a curve nobody asked for.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
|
||||
for level in [0u16, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
|
||||
let scene = f32::from(level) / f32::from(u16::MAX);
|
||||
let expected = (dr_types::Transfer::Srgb.encode(scene) * 255.0).round() as i32;
|
||||
let got = i32::from(rendered_level(&ctx, level, BaseCurve::IDENTITY));
|
||||
// Two 8-bit steps: the texture holding the demosaiced frame is
|
||||
// `Rgba16Float`, so a value round-trips through eleven mantissa bits
|
||||
// before it is encoded. That is well under one step at any level, and
|
||||
// the tolerance is for the rounding either side of it rather than for
|
||||
// the transform being approximate.
|
||||
assert!(
|
||||
(got - expected).abs() <= 2,
|
||||
"raw {level} rendered as {got}, expected about {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_is_monotone_through_the_whole_range() {
|
||||
// The property the spline's tangent limiting exists to guarantee, checked
|
||||
// where it actually matters: on the device, through the real uniform
|
||||
// packing. A curve that dipped anywhere would put a dark band across a
|
||||
// smooth gradient — a sky, most visibly — and it would read as a
|
||||
// rendering fault rather than as a bad profile.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
|
||||
let curve = six_d();
|
||||
let mut previous = 0u8;
|
||||
for step in 0..=16u32 {
|
||||
let level = (step * 65535 / 16) as u16;
|
||||
let value = rendered_level(&ctx, level, curve);
|
||||
assert!(
|
||||
value >= previous,
|
||||
"the curve fell from {previous} to {value} at raw level {level}"
|
||||
);
|
||||
previous = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
//! Capture sharpening, end to end on a real device.
|
||||
//!
|
||||
//! `dr-pipeline`'s own tests assert what the composer *generates* — the kernel
|
||||
//! extent, the uniforms, which pass encodes. None of them can tell whether the
|
||||
//! generated WGSL compiles, whether the second pass is handed what the first
|
||||
//! one wrote, or whether the result is sharpening rather than a shader that
|
||||
//! silently produced the input again. Those are questions only a GPU answers.
|
||||
//!
|
||||
//! # Reading the expected values
|
||||
//!
|
||||
//! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it
|
||||
//! non-linear, so the generated shader decodes sRGB before any operation runs
|
||||
//! and a black/white step reaches the detail stage as linear 0.0 and 1.0
|
||||
//! exactly. The last detail pass re-encodes. So a byte read back here is
|
||||
//! `srgb_encode(whatever the kernel produced in linear light)`, and an
|
||||
//! overshoot — the bright fringe an unsharp mask puts on the light side of an
|
||||
//! edge — cannot show above 255 on the white side of a full-scale step, and the
|
||||
//! undershoot on the dark side of one clips to black long before the halo has
|
||||
//! been drawn. The tests therefore use a **grey** step, from byte 90 to byte
|
||||
//! 150, which at 100% amount leaves the whole halo inside the representable
|
||||
//! range at both ends. Every expected value below is arithmetic on that step,
|
||||
//! not a number read off a previous run.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::ops::capture_sharpen::{AMOUNT, ID, RADIUS, THRESHOLD};
|
||||
use dr_pipeline::{Affects, EditGraph};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
// CI runners and headless machines may have no usable adapter. Skip rather
|
||||
// than fail, exactly as the rest of this crate's device tests do.
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A vertical step from `low` to `high`, changing at the middle column.
|
||||
///
|
||||
/// The one image whose sharpening is worth checking by hand: an unsharp mask
|
||||
/// must darken the last few columns before the step and brighten the first few
|
||||
/// after it, and leave everything further away exactly where it was. A gradient
|
||||
/// would blur to itself and hide a kernel that does nothing at all.
|
||||
fn step_edge(ctx: &GpuContext, size: u32, low: u8, high: u8) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..size * size)
|
||||
.flat_map(|i| {
|
||||
let v = if (i % size) < size / 2 { low } else { high };
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
|
||||
}
|
||||
|
||||
/// A flat field of one value.
|
||||
fn flat(ctx: &GpuContext, size: u32, value: u8) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..size * size)
|
||||
.flat_map(|_| [value, value, value, 255])
|
||||
.collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
|
||||
}
|
||||
|
||||
/// One row of the rendered image, red channel, as bytes.
|
||||
fn row(pixels: &[u8], width: u32, y: u32) -> Vec<u8> {
|
||||
(0..width)
|
||||
.map(|x| pixels[((y * width + x) * 4) as usize])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The develop chain with capture sharpening set.
|
||||
fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_param(ID, AMOUNT, amount);
|
||||
graph.set_param(ID, RADIUS, radius);
|
||||
graph.set_param(ID, THRESHOLD, threshold);
|
||||
graph
|
||||
}
|
||||
|
||||
/// Render one graph, with its detail stage, and read the pixels back.
|
||||
///
|
||||
/// The whole calling convention a frontend adopts, in five lines: compose both
|
||||
/// halves from one graph at one output space, ask the graph for the scale, and
|
||||
/// pass the invalidation key through.
|
||||
fn render(
|
||||
pass: &mut AdjustPass,
|
||||
graph: &EditGraph,
|
||||
source: &DemosaicedImage,
|
||||
out: u32,
|
||||
) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
pass.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsharp_mask_puts_a_halo_on_the_edge_and_leaves_the_rest_alone() {
|
||||
// What sharpening *is*, asserted as pixels rather than as "something
|
||||
// changed": an undershoot immediately before the transition, an overshoot
|
||||
// immediately after it, the step itself steeper than it was, and the flat
|
||||
// ground at either end untouched. A shader that ran the blur and forgot to
|
||||
// add the difference back would pass a "the image changed" test and fail
|
||||
// every one of these.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 64;
|
||||
let source = step_edge(&ctx, SIZE, 90, 150);
|
||||
|
||||
let plain = render(
|
||||
&mut AdjustPass::new(&ctx),
|
||||
&EditGraph::default_chain(),
|
||||
&source,
|
||||
SIZE,
|
||||
);
|
||||
let sharp = render(
|
||||
&mut AdjustPass::new(&ctx),
|
||||
&sharpened(100.0, 2.0, 0.0),
|
||||
&source,
|
||||
SIZE,
|
||||
);
|
||||
|
||||
let before = row(&plain, SIZE, SIZE / 2);
|
||||
let after = row(&sharp, SIZE, SIZE / 2);
|
||||
let edge = (SIZE / 2) as usize;
|
||||
|
||||
// The dark side of the transition is driven darker and the light side
|
||||
// lighter — the halo. Two pixels in, where a two-pixel-sigma kernel has
|
||||
// most of its response.
|
||||
assert!(
|
||||
after[edge - 2] < before[edge - 2],
|
||||
"the dark side of the edge should be pushed down: {} -> {}",
|
||||
before[edge - 2],
|
||||
after[edge - 2]
|
||||
);
|
||||
assert!(
|
||||
after[edge + 1] > before[edge + 1],
|
||||
"the light side of the edge should be pushed up: {} -> {}",
|
||||
before[edge + 1],
|
||||
after[edge + 1]
|
||||
);
|
||||
|
||||
// And the transition really is steeper across the same two columns.
|
||||
let slope = |r: &[u8]| r[edge] as i32 - r[edge - 1] as i32;
|
||||
assert!(
|
||||
slope(&after) > slope(&before),
|
||||
"sharpening must steepen the edge: {} -> {}",
|
||||
slope(&before),
|
||||
slope(&after)
|
||||
);
|
||||
|
||||
// Far from the edge there is nothing to sharpen, so nothing may move. This
|
||||
// is the property a kernel that forgot to normalise its weights breaks,
|
||||
// and it breaks it as a brightness shift over the whole photograph.
|
||||
for x in [0usize, 4, 8, SIZE as usize - 1] {
|
||||
assert!(
|
||||
after[x].abs_diff(before[x]) <= 1,
|
||||
"column {x} is flat ground and moved: {} -> {}",
|
||||
before[x],
|
||||
after[x]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_field_survives_any_amount_of_sharpening() {
|
||||
// The kernel sums to one — `(1 + a)` of the pixel minus `a` of its blur —
|
||||
// so a sky must come through bit for bit however far the slider is pushed.
|
||||
// The border is the part that is easy to get wrong: `tap` clamps, and a
|
||||
// kernel that normalised by an analytic integral instead of by the weights
|
||||
// it actually summed would draw a band around the whole frame.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 48;
|
||||
let source = flat(&ctx, SIZE, 128);
|
||||
|
||||
let plain = render(
|
||||
&mut AdjustPass::new(&ctx),
|
||||
&EditGraph::default_chain(),
|
||||
&source,
|
||||
SIZE,
|
||||
);
|
||||
let sharp = render(
|
||||
&mut AdjustPass::new(&ctx),
|
||||
&sharpened(100.0, 3.0, 0.0),
|
||||
&source,
|
||||
SIZE,
|
||||
);
|
||||
|
||||
for (i, (a, b)) in sharp.iter().zip(&plain).enumerate() {
|
||||
assert!(
|
||||
a.abs_diff(*b) <= 1,
|
||||
"pixel {} of a flat field moved: {b} -> {a}",
|
||||
i / 4
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_and_an_export_sharpen_the_same_photograph() {
|
||||
// TRACES: FR-DSP-1 — the decision this operation is most likely to get
|
||||
// wrong, and the one that is invisible until an export comes back wrong.
|
||||
//
|
||||
// The same edit, rendered at two resolutions of one source. The radius is
|
||||
// in source pixels, so the halo must cover the same *proportion of the
|
||||
// picture* at both: a fringe four source pixels wide is four source pixels
|
||||
// wide whether it was drawn on a half-size proxy or at full size.
|
||||
//
|
||||
// Read the radius as render pixels instead and the proxy's halo would be
|
||||
// twice as wide relative to the frame and roughly twice as strong, so what
|
||||
// was tuned on screen would not be what landed in the file. That is the
|
||||
// failure this catches, and it is a large one: the widths would differ by a
|
||||
// factor of two, not by a rounding.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SOURCE: u32 = 128;
|
||||
let source = step_edge(&ctx, SOURCE, 90, 150);
|
||||
// The widest radius the slider offers, so that even the half-size proxy
|
||||
// has a 1.5-pixel sigma and resolves it — the honest cut-off is tested in
|
||||
// `dr-pipeline`, and this test is about the case where both renders draw.
|
||||
// Asking for more would be asking for a photograph nobody can produce:
|
||||
// `EditGraph::set_param` clamps to the descriptor on the way in.
|
||||
let graph = sharpened(100.0, 3.0, 0.0);
|
||||
|
||||
// The halo, measured against the same edit with no sharpening at the same
|
||||
// size: how far from the transition the picture is still disturbed, as a
|
||||
// fraction of the frame, and how much deviation the halo carries in total.
|
||||
let measure = |out: u32| -> (f32, f32) {
|
||||
let plain = render(
|
||||
&mut AdjustPass::new(&ctx),
|
||||
&EditGraph::default_chain(),
|
||||
&source,
|
||||
out,
|
||||
);
|
||||
let sharp = render(&mut AdjustPass::new(&ctx), &graph, &source, out);
|
||||
let (a, b) = (row(&plain, out, out / 2), row(&sharp, out, out / 2));
|
||||
|
||||
let disturbed: Vec<usize> = (0..out as usize)
|
||||
.filter(|&x| b[x].abs_diff(a[x]) > 3)
|
||||
.collect();
|
||||
let first = *disturbed.first().expect("a halo");
|
||||
let last = *disturbed.last().expect("a halo");
|
||||
|
||||
// The halo's strength as an *area* — the sum of the deviations, scaled
|
||||
// by the width of a render pixel — rather than as its peak. A peak is
|
||||
// one sample of a smooth curve, and the two renders do not sample it at
|
||||
// the same place: the pixel next to the transition sits half a render
|
||||
// pixel from it, which is half a source pixel at export and a whole one
|
||||
// on the proxy, so their peaks would legitimately differ by more than
|
||||
// the property under test. An integral over the same curve does not
|
||||
// care where the samples fell.
|
||||
let area: f32 = (0..out as usize)
|
||||
.map(|x| b[x].abs_diff(a[x]) as f32)
|
||||
.sum::<f32>()
|
||||
/ out as f32;
|
||||
((last - first) as f32 / out as f32, area)
|
||||
};
|
||||
|
||||
let (proxy_width, proxy_area) = measure(SOURCE / 2);
|
||||
let (export_width, export_area) = measure(SOURCE);
|
||||
|
||||
assert!(
|
||||
(proxy_width - export_width).abs() < 0.06,
|
||||
"the halo covers {proxy_width:.3} of the proxy and {export_width:.3} \
|
||||
of the export; a radius tuned on screen must land in the file"
|
||||
);
|
||||
// The strength has to agree too. A viewport-scaled kernel would not only
|
||||
// be wider on the proxy, it would push the fringe further, because a wider
|
||||
// blur takes more away for the high-pass to add back — so the areas would
|
||||
// differ by considerably more than the sampling slack allowed here.
|
||||
let ratio = proxy_area / export_area;
|
||||
assert!(
|
||||
(0.75..1.35).contains(&ratio),
|
||||
"the halo carries {proxy_area:.2} on the proxy and {export_area:.2} at \
|
||||
export, a ratio of {ratio:.2}"
|
||||
);
|
||||
// And both are a real halo rather than two flat images agreeing.
|
||||
assert!(
|
||||
proxy_width > 0.05 && export_width > 0.05,
|
||||
"{proxy_width:.3} / {export_width:.3}"
|
||||
);
|
||||
assert!(proxy_area > 1.0 && export_area > 1.0, "{proxy_area} / {export_area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_threshold_leaves_shallow_modulation_where_it_found_it() {
|
||||
// What the threshold is for: sensor noise is shallow, and sharpening it is
|
||||
// the fastest way to make a clean frame look worse. Two images, one with a
|
||||
// strong edge and one with a shallow ripple, through the same gate — the
|
||||
// edge must still sharpen and the ripple must not.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 64;
|
||||
|
||||
// A four-code ripple: about 4% local contrast at this level, which is the
|
||||
// order of magnitude read noise reaches on a well-exposed frame — and well
|
||||
// under the 12.5% at which the gate below starts letting detail through.
|
||||
let ripple: Vec<u8> = (0..SIZE * SIZE)
|
||||
.flat_map(|i| {
|
||||
let v = if (i % SIZE) % 2 == 0 { 128u8 } else { 132 };
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
let ripple = DemosaicedImage::from_rgba8(&ctx, &ripple, SIZE, SIZE).expect("upload");
|
||||
let edge = step_edge(&ctx, SIZE, 90, 150);
|
||||
|
||||
let gated = sharpened(100.0, 1.0, 1.0);
|
||||
let ungated = sharpened(100.0, 1.0, 0.0);
|
||||
|
||||
let spread = |graph: &EditGraph, source: &DemosaicedImage| -> u8 {
|
||||
let pixels = render(&mut AdjustPass::new(&ctx), graph, source, SIZE);
|
||||
let line = row(&pixels, SIZE, SIZE / 2);
|
||||
// Peak-to-peak over the middle of the row, away from the border.
|
||||
let window = &line[8..24];
|
||||
window.iter().max().unwrap() - window.iter().min().unwrap()
|
||||
};
|
||||
|
||||
let ripple_open = spread(&ungated, &ripple);
|
||||
let ripple_gated = spread(&gated, &ripple);
|
||||
assert!(
|
||||
ripple_gated < ripple_open,
|
||||
"the gate must hold shallow modulation back: {ripple_open} -> \
|
||||
{ripple_gated}"
|
||||
);
|
||||
|
||||
// The edge is deep modulation and must come through the same gate
|
||||
// sharpened — a threshold that flattens everything is not a threshold.
|
||||
let plain_edge = {
|
||||
let pixels = render(
|
||||
&mut AdjustPass::new(&ctx),
|
||||
&EditGraph::default_chain(),
|
||||
&edge,
|
||||
SIZE,
|
||||
);
|
||||
row(&pixels, SIZE, SIZE / 2)
|
||||
};
|
||||
let gated_edge = {
|
||||
let pixels = render(&mut AdjustPass::new(&ctx), &gated, &edge, SIZE);
|
||||
row(&pixels, SIZE, SIZE / 2)
|
||||
};
|
||||
let mid = (SIZE / 2) as usize;
|
||||
assert!(
|
||||
gated_edge[mid - 1] < plain_edge[mid - 1],
|
||||
"a real edge must still sharpen through the gate: {} -> {}",
|
||||
plain_edge[mid - 1],
|
||||
gated_edge[mid - 1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharpening_an_edge_does_not_change_its_colour() {
|
||||
// The reason the high-pass is applied as a gain on the three channels
|
||||
// rather than as an offset. An offset moves a saturated colour towards
|
||||
// grey as it brightens it, so a sharpened red roof gets a pink fringe —
|
||||
// which reads as chromatic aberration and gets blamed on the lens.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 64;
|
||||
|
||||
// A step between two saturated reds of different brightness: the ratios
|
||||
// between the channels are the colour, and they must survive the halo.
|
||||
let data: Vec<u8> = (0..SIZE * SIZE)
|
||||
.flat_map(|i| {
|
||||
if (i % SIZE) < SIZE / 2 {
|
||||
[80u8, 30, 30, 255]
|
||||
} else {
|
||||
[200, 75, 75, 255]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let source = DemosaicedImage::from_rgba8(&ctx, &data, SIZE, SIZE).expect("upload");
|
||||
|
||||
let pixels = render(
|
||||
&mut AdjustPass::new(&ctx),
|
||||
&sharpened(60.0, 2.0, 0.0),
|
||||
&source,
|
||||
SIZE,
|
||||
);
|
||||
|
||||
// Sampled inside the halo, where an additive sharpener would have washed
|
||||
// the colour out most.
|
||||
let y = SIZE / 2;
|
||||
for x in [SIZE / 2 - 2, SIZE / 2 + 1] {
|
||||
let i = ((y * SIZE + x) * 4) as usize;
|
||||
let (r, g, b) = (pixels[i] as f32, pixels[i + 1] as f32, pixels[i + 2] as f32);
|
||||
assert!(r > g && r > b, "the fringe lost its hue at column {x}");
|
||||
// Green and blue started equal and must stay equal: an offset would
|
||||
// keep them equal too, but the *ratio* to red is what moves, and this
|
||||
// is the assertion that it did not.
|
||||
let saturation = (r - g) / r;
|
||||
assert!(
|
||||
saturation > 0.55,
|
||||
"column {x} washed out: rgb {r} {g} {b}, saturation {saturation:.3}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
|
||||
// The two costs that are ruinous per frame and invisible in the output.
|
||||
// A sharpening slider is dragged continuously, so this is the difference
|
||||
// between a control that tracks the mouse and one that stutters.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 48;
|
||||
let source = step_edge(&ctx, SIZE, 90, 150);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let mut graph = sharpened(40.0, 1.0, 0.0);
|
||||
|
||||
render(&mut pass, &graph, &source, SIZE);
|
||||
let pipelines = pass.cached_detail_pipelines();
|
||||
let allocations = pass.detail_allocations();
|
||||
assert_eq!(pipelines, 2, "one per axis of the separable mask");
|
||||
assert_eq!(allocations, 2, "the colour result, and one hand-off");
|
||||
assert_eq!(pass.detail_dispatches(), 2);
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
|
||||
for amount in [50.0, 60.0, 70.0, 80.0] {
|
||||
graph.set_param(ID, AMOUNT, amount);
|
||||
render(&mut pass, &graph, &source, SIZE);
|
||||
}
|
||||
assert_eq!(
|
||||
pass.cached_detail_pipelines(),
|
||||
pipelines,
|
||||
"an amount is a uniform, not a shader"
|
||||
);
|
||||
assert_eq!(
|
||||
pass.detail_allocations(),
|
||||
allocations,
|
||||
"a steady viewport must allocate nothing"
|
||||
);
|
||||
// TRACES: FR-DEV-3d — and the operational point of `Affects::Detail`:
|
||||
// sharpening is downstream of every fused operation, so dragging it must
|
||||
// not re-run them.
|
||||
assert_eq!(
|
||||
pass.colour_dispatches(),
|
||||
1,
|
||||
"the fused colour pass re-ran for a change it does not depend on"
|
||||
);
|
||||
|
||||
// The radius is also only a uniform, even though it changes the kernel
|
||||
// extent — the loop bound is read from the uniform block rather than
|
||||
// baked into the source, which is what keeps a drag off the compiler.
|
||||
graph.set_param(ID, RADIUS, 2.5);
|
||||
render(&mut pass, &graph, &source, SIZE);
|
||||
assert_eq!(pass.cached_detail_pipelines(), pipelines);
|
||||
graph.set_param(ID, THRESHOLD, 0.3);
|
||||
render(&mut pass, &graph, &source, SIZE);
|
||||
assert_eq!(pass.cached_detail_pipelines(), pipelines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
|
||||
// The failure mode that the pass-through exists to prevent, proved on a
|
||||
// device rather than argued about. With the radius finer than a render
|
||||
// pixel the operation declines to sharpen — but it is still active, so the
|
||||
// fused pass has already been composed to hand on unclipped linear values,
|
||||
// and something must still perform the output transform. An empty chain
|
||||
// here would not be a soft preview: it would be a hard error out of
|
||||
// `render_detailed`, on the most ordinary develop view there is.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SOURCE: u32 = 128;
|
||||
const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame
|
||||
let source = step_edge(&ctx, SOURCE, 90, 150);
|
||||
|
||||
let graph = sharpened(100.0, 1.0, 0.0);
|
||||
let scale = graph.render_scale((SOURCE, SOURCE), (RENDER, RENDER));
|
||||
assert!(!scale.resolves(1.0), "the premise of this test");
|
||||
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let sharp = render(&mut pass, &graph, &source, RENDER);
|
||||
assert_eq!(pass.detail_dispatches(), 1, "one pass, and it only encodes");
|
||||
|
||||
// And what reaches the screen is the unsharpened picture, not a black
|
||||
// frame, a linear one, or a guess.
|
||||
let plain = render(
|
||||
&mut AdjustPass::new(&ctx),
|
||||
&EditGraph::default_chain(),
|
||||
&source,
|
||||
RENDER,
|
||||
);
|
||||
for (i, (a, b)) in sharp.iter().zip(&plain).enumerate() {
|
||||
assert!(
|
||||
a.abs_diff(*b) <= 1,
|
||||
"pixel {} differs from the unsharpened render: {b} -> {a}",
|
||||
i / 4
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_operation_is_reachable_by_the_ids_a_frontend_will_use() {
|
||||
// FR-DEV-3c: adding an operation needs no UI change, which is only true if
|
||||
// the panel can find it through the capability list. A typo between the
|
||||
// declaration's `id:` and the descriptor's would place it in the chain
|
||||
// under one name and address it under another.
|
||||
let graph = EditGraph::default_chain();
|
||||
let cap = graph
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id == ID)
|
||||
.expect("capture sharpening is in the default chain");
|
||||
let names: Vec<ParamId> = cap.params.iter().map(|p| p.id).collect();
|
||||
assert_eq!(names, vec![AMOUNT, RADIUS, THRESHOLD]);
|
||||
assert!(!cap.active, "a fresh chain is not sharpening anything");
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
//! The neighbourhood stage, end to end on a real device.
|
||||
//!
|
||||
//! `dr-pipeline`'s own tests assert what the composer *generates*; nothing
|
||||
//! there can tell whether the WGSL compiles, whether pass two is handed what
|
||||
//! pass one wrote, or whether the output transform happens exactly once. Those
|
||||
//! are questions only a GPU answers, and they are the ones that decide whether
|
||||
//! a future sharpening operation works or draws nonsense.
|
||||
//!
|
||||
//! The consumer is `detail_probe`, a separable box blur that is not a develop
|
||||
//! operation (see `dr_pipeline::detail::probe`). A box blur is used because its
|
||||
//! answer is known in closed form: over a step edge it produces a ramp exactly
|
||||
//! `2r + 1` pixels wide with a computable value at every step, so these tests
|
||||
//! assert **pixels** rather than "something changed".
|
||||
//!
|
||||
//! # Reading the expected values
|
||||
//!
|
||||
//! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it
|
||||
//! non-linear, so the generated shader decodes sRGB before any operation runs.
|
||||
//! A black/white step therefore reaches the detail stage as linear 0.0 and 1.0
|
||||
//! exactly. The blur averages those, and the last detail pass re-encodes. So
|
||||
//! the expected byte at a column is `srgb_encode(white_taps / (2r + 1))`, with
|
||||
//! taps clamped at the border — which is exactly what `expected_profile`
|
||||
//! computes.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::{OpId, ParamId};
|
||||
use dr_pipeline::detail::probe::BoxBlur;
|
||||
use dr_pipeline::{Affects, EditGraph, OutputMode};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
const PROBE: OpId = OpId("detail_probe");
|
||||
const RADIUS: ParamId = ParamId("radius");
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
// CI runners and headless machines may have no usable adapter. Skip rather
|
||||
// than fail, exactly as the rest of this crate's device tests do.
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A vertical step edge: black to the left of `size / 2`, white to the right.
|
||||
///
|
||||
/// The one image whose blur is worth checking by hand. A gradient would
|
||||
/// average to itself and hide a kernel that is off by one; a step does not.
|
||||
fn step_edge(ctx: &GpuContext, size: u32) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..size * size)
|
||||
.flat_map(|i| {
|
||||
let x = i % size;
|
||||
let v = if x < size / 2 { 0u8 } else { 255 };
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
|
||||
}
|
||||
|
||||
/// One row of the rendered image, red channel, as bytes.
|
||||
fn row(pixels: &[u8], size: u32, y: u32) -> Vec<u8> {
|
||||
(0..size)
|
||||
.map(|x| pixels[((y * size + x) * 4) as usize])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn srgb_encode(v: f32) -> u8 {
|
||||
let e = if v <= 0.003_130_8 {
|
||||
v * 12.92
|
||||
} else {
|
||||
1.055 * v.powf(1.0 / 2.4) - 0.055
|
||||
};
|
||||
(e.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
/// What a separable box blur of radius `r` must produce over the step edge.
|
||||
fn expected_profile(size: u32, r: i32) -> Vec<u8> {
|
||||
let last = size as i32 - 1;
|
||||
let edge = (size / 2) as i32;
|
||||
(0..size as i32)
|
||||
.map(|x| {
|
||||
let white = (-r..=r)
|
||||
.filter(|i| (x + i).clamp(0, last) >= edge)
|
||||
.count();
|
||||
srgb_encode(white as f32 / (2 * r + 1) as f32)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render one graph, with its detail stage, and read the pixels back.
|
||||
///
|
||||
/// This is the whole calling convention a frontend has to adopt, in five
|
||||
/// lines: compose both halves from one graph at one output space, ask the
|
||||
/// graph for the scale, and pass the invalidation key through.
|
||||
fn render(
|
||||
ctx: &GpuContext,
|
||||
pass: &mut AdjustPass,
|
||||
graph: &EditGraph,
|
||||
source: &DemosaicedImage,
|
||||
out: u32,
|
||||
) -> Vec<u8> {
|
||||
let _ = ctx;
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
pass.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neighbourhood_pass_produces_the_pixels_it_should() {
|
||||
// The whole seam, proved once: an operation that reads its neighbours runs
|
||||
// on the GPU, and the values it writes are the ones a box blur is defined
|
||||
// to write. Not "the edge got softer" — every byte of the ramp.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 64;
|
||||
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
graph.set_param(PROBE, RADIUS, 0.0625); // 4 px on a 64 px edge
|
||||
let source = step_edge(&ctx, SIZE);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let pixels = render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
let got = row(&pixels, SIZE, SIZE / 2);
|
||||
|
||||
let r = BoxBlur::with_radius(0.0625).kernel(graph.render_scale((SIZE, SIZE), (SIZE, SIZE)));
|
||||
assert_eq!(r, 4, "5/64 of the shorter edge, rounded");
|
||||
let want = expected_profile(SIZE, r as i32);
|
||||
|
||||
for (x, (a, b)) in got.iter().zip(&want).enumerate() {
|
||||
assert!(
|
||||
a.abs_diff(*b) <= 2,
|
||||
"column {x}: got {a}, expected {b}\ngot: {got:?}\nwant: {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_second_pass_reads_what_the_first_one_wrote() {
|
||||
// The ping-pong, stated as a property of the picture rather than of the
|
||||
// plumbing. A separable blur is symmetric: applied to a *horizontal* step
|
||||
// it must also soften a horizontal edge in the other direction. Wire the
|
||||
// second pass to read the original again and the vertical smear vanishes,
|
||||
// which is exactly what this sees.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 64;
|
||||
|
||||
// A quadrant image: the vertical pass has something to do only if it is
|
||||
// reading the horizontal pass's output rather than the source.
|
||||
let data: Vec<u8> = (0..SIZE * SIZE)
|
||||
.flat_map(|i| {
|
||||
let (x, y) = (i % SIZE, i / SIZE);
|
||||
let v = if (x < SIZE / 2) == (y < SIZE / 2) {
|
||||
0u8
|
||||
} else {
|
||||
255
|
||||
};
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
let source = DemosaicedImage::from_rgba8(&ctx, &data, SIZE, SIZE).expect("upload");
|
||||
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
graph.set_param(PROBE, RADIUS, 0.0625);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let pixels = render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
|
||||
// Two separable passes compose into a true two-dimensional box average —
|
||||
// but only if the second reads the first's output. Computed in closed form
|
||||
// over the same window the shader uses, so this is an assertion about
|
||||
// values rather than about direction.
|
||||
let r = 4i32;
|
||||
let last = SIZE as i32 - 1;
|
||||
let half = (SIZE / 2) as i32;
|
||||
let quadrant_is_black = |x: i32, y: i32| (x < half) == (y < half);
|
||||
let want: Vec<u8> = (0..SIZE as i32)
|
||||
.map(|x| {
|
||||
let y = half;
|
||||
let mut white = 0usize;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (sx, sy) = ((x + dx).clamp(0, last), (y + dy).clamp(0, last));
|
||||
if !quadrant_is_black(sx, sy) {
|
||||
white += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
srgb_encode(white as f32 / ((2 * r + 1) * (2 * r + 1)) as f32)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let got = row(&pixels, SIZE, SIZE / 2);
|
||||
for (x, (a, b)) in got.iter().zip(&want).enumerate() {
|
||||
// A second pass reading the *source* instead would leave column 20 at
|
||||
// 255 where a real 2D average puts it near 196 — so the failure this
|
||||
// catches is loud, not marginal.
|
||||
assert!(
|
||||
a.abs_diff(*b) <= 2,
|
||||
"column {x}: got {a}, expected {b}\ngot: {got:?}\nwant: {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inactive_detail_operation_costs_exactly_nothing() {
|
||||
// The rule the whole pipeline rests on, carried into this stage. A
|
||||
// photograph with no sharpening must render through the single fused
|
||||
// dispatch it always did, allocate no intermediate, and — the part worth
|
||||
// checking — produce byte-identical pixels to a graph that has no
|
||||
// neighbourhood operation in it at all.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 32;
|
||||
let source = step_edge(&ctx, SIZE);
|
||||
|
||||
let probe = EditGraph::with_detail_probe();
|
||||
assert_eq!(
|
||||
probe.compose_for(ColourSpace::Srgb).output_mode,
|
||||
OutputMode::Encoded,
|
||||
"a neutral detail operation must not change how the fused pass ends"
|
||||
);
|
||||
|
||||
let mut with_probe = AdjustPass::new(&ctx);
|
||||
let a = render(&ctx, &mut with_probe, &probe, &source, SIZE);
|
||||
assert_eq!(with_probe.colour_dispatches(), 1);
|
||||
assert_eq!(with_probe.detail_dispatches(), 0);
|
||||
assert_eq!(with_probe.detail_allocations(), 0, "nothing was allocated");
|
||||
|
||||
let plain = EditGraph::default_chain();
|
||||
let mut without = AdjustPass::new(&ctx);
|
||||
let b = render(&ctx, &mut without, &plain, &source, SIZE);
|
||||
|
||||
assert_eq!(a, b, "an operation at its defaults must not touch the image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_detail_parameter_does_not_re_run_the_colour_pass() {
|
||||
// TRACES: FR-DEV-3d, and the operational point of `Affects::Detail`.
|
||||
//
|
||||
// Invisible in the output by construction — the picture is meant to be
|
||||
// whatever the sharpening says whichever way it was computed — so a
|
||||
// dispatch counter is the only thing that can see it. Without this, the
|
||||
// whole invalidation story is a comment.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 64;
|
||||
let source = step_edge(&ctx, SIZE);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
graph.set_param(PROBE, RADIUS, 0.0625);
|
||||
render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
assert_eq!(pass.detail_dispatches(), 2, "a separable blur is two passes");
|
||||
|
||||
// Drag the sharpening slider. The colour chain is untouched, so the linear
|
||||
// intermediate it wrote is still exactly right.
|
||||
graph.set_param(PROBE, RADIUS, 0.09);
|
||||
render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
assert_eq!(
|
||||
pass.colour_dispatches(),
|
||||
1,
|
||||
"the fused colour pass re-ran for a change it does not depend on"
|
||||
);
|
||||
assert_eq!(pass.detail_dispatches(), 4);
|
||||
|
||||
// Now move exposure. The detail stage reads what the colour pass wrote, so
|
||||
// this one genuinely does have to re-run both — anything else would show a
|
||||
// sharpened version of the previous exposure.
|
||||
graph.set_param(
|
||||
dr_pipeline::ops::exposure::ID,
|
||||
dr_pipeline::ops::exposure::EXPOSURE,
|
||||
1.0,
|
||||
);
|
||||
render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
assert_eq!(pass.colour_dispatches(), 2);
|
||||
assert_eq!(pass.detail_dispatches(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_slider_recompiles_nothing_and_reallocates_nothing() {
|
||||
// The two costs that are ruinous per frame and invisible in the output.
|
||||
// Both are the same rule the rest of the crate follows: values ride in a
|
||||
// uniform buffer, and textures are reallocated on resize rather than on
|
||||
// change.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 48;
|
||||
let source = step_edge(&ctx, SIZE);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
|
||||
graph.set_param(PROBE, RADIUS, 0.05);
|
||||
render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
let pipelines = pass.cached_detail_pipelines();
|
||||
let allocations = pass.detail_allocations();
|
||||
assert_eq!(pipelines, 2, "one per pass of the separable blur");
|
||||
assert_eq!(allocations, 2, "the colour result, and one hand-off");
|
||||
|
||||
for radius in [0.06, 0.07, 0.08, 0.09] {
|
||||
graph.set_param(PROBE, RADIUS, radius);
|
||||
render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
}
|
||||
assert_eq!(
|
||||
pass.cached_detail_pipelines(),
|
||||
pipelines,
|
||||
"a radius is a uniform, not a shader"
|
||||
);
|
||||
assert_eq!(
|
||||
pass.detail_allocations(),
|
||||
allocations,
|
||||
"a steady viewport must allocate nothing"
|
||||
);
|
||||
|
||||
// A resize is the one thing that legitimately reallocates.
|
||||
render(&ctx, &mut pass, &graph, &source, SIZE / 2);
|
||||
assert!(pass.detail_allocations() > allocations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_and_an_export_agree_about_where_the_effect_lands() {
|
||||
// TRACES: FR-DSP-1 — the subtle one, and the reason `RenderScale` exists.
|
||||
//
|
||||
// The same edit, rendered at two resolutions. A radius stored as a
|
||||
// fraction of the shorter edge must produce a transition covering the same
|
||||
// *proportion* of the frame at both, or a sharpening tuned on screen is a
|
||||
// different sharpening in the exported file.
|
||||
//
|
||||
// The tolerance is a pixel's worth at the smaller size, because the kernel
|
||||
// is an integer count and 6.25% of 64 pixels is not 6.25% of 128. That
|
||||
// rounding is the whole of the error, and it is bounded by half a render
|
||||
// pixel by construction.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SOURCE: u32 = 128;
|
||||
let source = step_edge(&ctx, SOURCE);
|
||||
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
graph.set_param(PROBE, RADIUS, 0.0625);
|
||||
|
||||
let spread = |out: u32| -> f32 {
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let pixels = render(&ctx, &mut pass, &graph, &source, out);
|
||||
let line = row(&pixels, out, out / 2);
|
||||
// Where the ramp starts and ends, in fractions of the frame.
|
||||
let first = line.iter().position(|&v| v > 4).expect("a ramp") as f32;
|
||||
let last = line.iter().rposition(|&v| v < 251).expect("a ramp") as f32;
|
||||
(last - first) / out as f32
|
||||
};
|
||||
|
||||
let proxy = spread(SOURCE / 2);
|
||||
let export = spread(SOURCE);
|
||||
assert!(
|
||||
(proxy - export).abs() < 0.03,
|
||||
"the effect covers {proxy:.3} of the proxy and {export:.3} of the \
|
||||
export; a radius tuned on screen must land in the file"
|
||||
);
|
||||
// And it is a real transition in both, not two flat images agreeing.
|
||||
assert!(proxy > 0.08 && export > 0.08, "{proxy:.3} / {export:.3}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_halves_of_one_composition_must_be_dispatched_together() {
|
||||
// The failure this guards is a bad one to debug: a shader composed to hand
|
||||
// on linear working values, bound to an rgba8 storage texture. wgpu
|
||||
// rejects it, but the message is about a bind group, a long way from the
|
||||
// caller that composed one half of an edit and rendered the other.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 32;
|
||||
let source = step_edge(&ctx, SIZE);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
graph.set_param(PROBE, RADIUS, 0.0625);
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
assert_eq!(shader.output_mode, OutputMode::LinearWorking);
|
||||
|
||||
let err = pass
|
||||
.render_masked(&source, &shader, SIZE, SIZE, None)
|
||||
.expect_err("a linear-working shader has no business in the plain path");
|
||||
assert!(
|
||||
format!("{err}").contains("render_detailed"),
|
||||
"the error should name the way out: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_chain_falls_through_to_the_ordinary_render() {
|
||||
// A caller that always goes through `render_detailed` — which is what a
|
||||
// frontend will do, since it does not want to branch on whether the user
|
||||
// has sharpening on — must pay exactly nothing for the edits that have
|
||||
// none.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 32;
|
||||
let source = step_edge(&ctx, SIZE);
|
||||
let graph = EditGraph::default_chain();
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
|
||||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
assert!(detail.is_empty());
|
||||
|
||||
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
|
||||
.expect("render");
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
assert_eq!(pass.detail_dispatches(), 0);
|
||||
assert_eq!(pass.detail_allocations(), 0);
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
//! Local adjustments, end to end on a device.
|
||||
//!
|
||||
//! The unit tests either side of this one check halves: `dr-pipeline` asserts
|
||||
//! the generated WGSL says the right thing, and `dr-gpu`'s mask tests assert
|
||||
//! an array of the right shape comes out. Neither would notice if the two
|
||||
//! agreed with each other and both were wrong — a mask sampled with x and y
|
||||
//! swapped satisfies both.
|
||||
//!
|
||||
//! So this renders a real frame and reads the pixels back: the masked region
|
||||
//! must change, the rest must not, and the boundary must fall where the label
|
||||
//! field says it does.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::{ops, EditGraph, Framing};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
const SIZE: u32 = 32;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A flat mid-grey JPEG-path image, so any change is the adjustment's.
|
||||
fn grey(ctx: &GpuContext) -> DemosaicedImage {
|
||||
grey_at(ctx, SIZE, SIZE)
|
||||
}
|
||||
|
||||
fn grey_at(ctx: &GpuContext, w: u32, h: u32) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..w * h).flat_map(|_| [128, 128, 128, 255]).collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload")
|
||||
}
|
||||
|
||||
/// Two regions: 0 is the left half, 1 the right.
|
||||
fn split_field(ctx: &GpuContext) -> LabelField {
|
||||
let labels: Vec<u32> = (0..SIZE * SIZE)
|
||||
.map(|i| u32::from(i % SIZE >= SIZE / 2))
|
||||
.collect();
|
||||
LabelField::upload(ctx, &labels, SIZE, SIZE, 2).expect("label upload")
|
||||
}
|
||||
|
||||
/// A layer brightening whatever it covers, by a lot, so it cannot be missed.
|
||||
fn brighten(source: MaskSource) -> MaskLayer {
|
||||
let mut layer = MaskLayer::new("m1", source);
|
||||
layer.set_param("exposure", ParamId("exposure"), 2.0);
|
||||
layer
|
||||
}
|
||||
|
||||
fn luma_at(pixels: &[u8], x: u32, y: u32) -> u8 {
|
||||
pixels[((y * SIZE + x) * 4) as usize]
|
||||
}
|
||||
|
||||
/// Render `stack` over flat grey and hand back the RGBA8 result.
|
||||
fn render(ctx: &GpuContext, stack: &MaskStack, field: Option<&LabelField>) -> Vec<u8> {
|
||||
render_at(ctx, stack, field, SIZE, SIZE)
|
||||
}
|
||||
|
||||
fn render_at(
|
||||
ctx: &GpuContext,
|
||||
stack: &MaskStack,
|
||||
field: Option<&LabelField>,
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Vec<u8> {
|
||||
let source = grey_at(ctx, w, h);
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
);
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks.render(stack, field, None, w, h).expect("rasterise");
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render_masked(&source, &shader, w, h, Some(array))
|
||||
.expect("render");
|
||||
adjust.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_region_mask_changes_only_the_regions_it_names() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let field = split_field(&ctx);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(brighten(MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0],
|
||||
}));
|
||||
|
||||
let pixels = render(&ctx, &stack, Some(&field));
|
||||
|
||||
// Sampled well inside each half, clear of the feathered boundary.
|
||||
let inside = luma_at(&pixels, 4, SIZE / 2);
|
||||
let outside = luma_at(&pixels, SIZE - 5, SIZE / 2);
|
||||
|
||||
assert!(
|
||||
inside > outside + 40,
|
||||
"the masked half should be much brighter: {inside} vs {outside}"
|
||||
);
|
||||
assert!(
|
||||
(120..=136).contains(&outside),
|
||||
"the unmasked half must be untouched mid-grey, got {outside}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The failure a swapped axis or an inverted comparison would produce, and
|
||||
/// which the "inside is brighter" assertion alone would not catch.
|
||||
#[test]
|
||||
fn inverting_a_region_mask_swaps_which_half_moves() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let field = split_field(&ctx);
|
||||
|
||||
let mut layer = brighten(MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0],
|
||||
});
|
||||
layer.invert = true;
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
|
||||
let pixels = render(&ctx, &stack, Some(&field));
|
||||
let left = luma_at(&pixels, 4, SIZE / 2);
|
||||
let right = luma_at(&pixels, SIZE - 5, SIZE / 2);
|
||||
|
||||
assert!(
|
||||
right > left + 40,
|
||||
"inverted, the *other* half should brighten: left {left}, right {right}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_scales_the_effect() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let field = split_field(&ctx);
|
||||
let source = MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0],
|
||||
};
|
||||
|
||||
let mut full = MaskStack::new();
|
||||
full.push(brighten(source.clone()));
|
||||
|
||||
let mut half = MaskStack::new();
|
||||
let mut layer = brighten(source);
|
||||
layer.opacity = 0.5;
|
||||
half.push(layer);
|
||||
|
||||
let at_full = luma_at(&render(&ctx, &full, Some(&field)), 4, SIZE / 2);
|
||||
let at_half = luma_at(&render(&ctx, &half, Some(&field)), 4, SIZE / 2);
|
||||
let untouched = 128;
|
||||
|
||||
assert!(
|
||||
at_half > untouched && at_half < at_full,
|
||||
"half opacity should land between neutral and full: {untouched} < {at_half} < {at_full}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linear_gradient_ramps_across_the_frame() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(brighten(MaskSource::Linear {
|
||||
centre: (0.5, 0.5),
|
||||
angle: 0.0,
|
||||
width: 1.0,
|
||||
}));
|
||||
|
||||
let pixels = render(&ctx, &stack, None);
|
||||
let left = luma_at(&pixels, 1, SIZE / 2);
|
||||
let middle = luma_at(&pixels, SIZE / 2, SIZE / 2);
|
||||
let right = luma_at(&pixels, SIZE - 2, SIZE / 2);
|
||||
|
||||
assert!(
|
||||
left < middle && middle < right,
|
||||
"a horizontal ramp should increase left to right: {left}, {middle}, {right}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radial_mask_is_strongest_at_its_centre() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(brighten(MaskSource::Radial {
|
||||
centre: (0.5, 0.5),
|
||||
radii: (0.3, 0.3),
|
||||
angle: 0.0,
|
||||
feather: 0.5,
|
||||
}));
|
||||
|
||||
let pixels = render(&ctx, &stack, None);
|
||||
let centre = luma_at(&pixels, SIZE / 2, SIZE / 2);
|
||||
let corner = luma_at(&pixels, 1, 1);
|
||||
|
||||
assert!(
|
||||
centre > corner + 40,
|
||||
"the centre should carry the effect: {centre} vs corner {corner}"
|
||||
);
|
||||
assert!(
|
||||
(120..=136).contains(&corner),
|
||||
"outside the radius must be untouched, got {corner}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two layers must not read each other's slice.
|
||||
#[test]
|
||||
fn stacked_layers_use_their_own_masks() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let field = split_field(&ctx);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
// Left half up.
|
||||
stack.push(brighten(MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0],
|
||||
}));
|
||||
// Right half down.
|
||||
let mut darken = MaskLayer::new("m2", MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![1],
|
||||
});
|
||||
darken.set_param("exposure", ParamId("exposure"), -2.0);
|
||||
stack.push(darken);
|
||||
|
||||
let pixels = render(&ctx, &stack, Some(&field));
|
||||
let left = luma_at(&pixels, 4, SIZE / 2);
|
||||
let right = luma_at(&pixels, SIZE - 5, SIZE / 2);
|
||||
|
||||
assert!(left > 150, "left should have brightened, got {left}");
|
||||
assert!(right < 100, "right should have darkened, got {right}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brush strokes (ARCH §5.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UNTOUCHED: u8 = 128;
|
||||
|
||||
fn luma_in(pixels: &[u8], w: u32, x: u32, y: u32) -> u8 {
|
||||
pixels[((y * w + x) * 4) as usize]
|
||||
}
|
||||
|
||||
/// One gesture: whether it erases, its radius, its flow, and its path.
|
||||
type Gesture = (bool, f32, f32, Vec<(f32, f32)>);
|
||||
|
||||
/// A brightening layer with the given gestures already painted onto it.
|
||||
fn painted(gestures: &[Gesture]) -> MaskLayer {
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
for (erase, radius, flow, path) in gestures {
|
||||
layer.begin_stroke(*erase, *radius, 0.9, *flow);
|
||||
for &(x, y) in path {
|
||||
layer.extend_stroke(x, y);
|
||||
}
|
||||
layer.end_stroke();
|
||||
}
|
||||
layer
|
||||
}
|
||||
|
||||
fn stack_of(layer: MaskLayer) -> MaskStack {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
stack
|
||||
}
|
||||
|
||||
/// The whole feature, at its simplest: paint somewhere, and that is where the
|
||||
/// adjustment lands.
|
||||
///
|
||||
/// Painted across the top rather than down the middle, because a mask drawn
|
||||
/// upside down is symmetric about the middle and a centred stroke would not
|
||||
/// notice — and the vertex shader that draws a stroke has to flip y to reach
|
||||
/// clip space, which is exactly the kind of thing that is wrong once.
|
||||
#[test]
|
||||
fn a_stroke_paints_where_it_was_drawn_and_nowhere_else() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let stack = stack_of(painted(&[(
|
||||
false,
|
||||
0.1,
|
||||
1.0,
|
||||
vec![(0.2, 0.25), (0.8, 0.25)],
|
||||
)]));
|
||||
let pixels = render(&ctx, &stack, None);
|
||||
|
||||
let under = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 4);
|
||||
let below = luma_in(&pixels, SIZE, SIZE / 2, SIZE * 3 / 4);
|
||||
|
||||
assert!(
|
||||
under > UNTOUCHED + 40,
|
||||
"the stroke should have brightened the upper quarter, got {under}"
|
||||
);
|
||||
assert!(
|
||||
(120..=136).contains(&below),
|
||||
"the lower half was never painted and must be untouched, got {below}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The failure a bounding box that is not grown by the radius produces: a tap
|
||||
/// has no extent at all, so its quad has no area and nothing is drawn. Silent,
|
||||
/// and it looks exactly like a brush that ignores short gestures.
|
||||
#[test]
|
||||
fn a_tap_paints_a_dab() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let stack = stack_of(painted(&[(false, 0.2, 1.0, vec![(0.5, 0.5)])]));
|
||||
let pixels = render(&ctx, &stack, None);
|
||||
|
||||
let centre = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2);
|
||||
let corner = luma_in(&pixels, SIZE, 1, 1);
|
||||
assert!(centre > 180, "the dab should be there, got {centre}");
|
||||
assert!(
|
||||
(120..=136).contains(&corner),
|
||||
"and only there, got {corner}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Painting must be able to erase, or a mask is one mistake away from being
|
||||
/// started again.
|
||||
#[test]
|
||||
fn an_erasing_stroke_takes_back_what_was_painted() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let stack = stack_of(painted(&[
|
||||
(false, 0.25, 1.0, vec![(0.15, 0.5), (0.85, 0.5)]),
|
||||
(true, 0.12, 1.0, vec![(0.5, 0.5)]),
|
||||
]));
|
||||
let pixels = render(&ctx, &stack, None);
|
||||
|
||||
let erased = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2);
|
||||
let kept = luma_in(&pixels, SIZE, 3, SIZE / 2);
|
||||
|
||||
assert!(
|
||||
(120..=136).contains(&erased),
|
||||
"the erased middle should be back to untouched grey, got {erased}"
|
||||
);
|
||||
assert!(
|
||||
kept > 180,
|
||||
"the ends of the stroke are still painted, got {kept}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Order is the mask. The same two gestures the other way round leave the
|
||||
/// paint alone, and a rasteriser that composited by kind rather than by
|
||||
/// sequence would give the same answer to both.
|
||||
#[test]
|
||||
fn erasing_before_painting_removes_nothing() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let stack = stack_of(painted(&[
|
||||
(true, 0.12, 1.0, vec![(0.5, 0.5)]),
|
||||
(false, 0.25, 1.0, vec![(0.15, 0.5), (0.85, 0.5)]),
|
||||
]));
|
||||
let pixels = render(&ctx, &stack, None);
|
||||
|
||||
let middle = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2);
|
||||
assert!(
|
||||
middle > 180,
|
||||
"an erase before the paint has nothing to take away, got {middle}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A stroke that crosses itself must not build up where it did. Summing the
|
||||
/// segments instead of taking the nearest would make every circle and every
|
||||
/// scribble blotchy — and at full flow it would not show at all, which is why
|
||||
/// this paints at half.
|
||||
#[test]
|
||||
fn a_stroke_that_doubles_back_does_not_build_up() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let once = stack_of(painted(&[(
|
||||
false,
|
||||
0.15,
|
||||
0.5,
|
||||
vec![(0.1, 0.5), (0.9, 0.5)],
|
||||
)]));
|
||||
let twice = stack_of(painted(&[(
|
||||
false,
|
||||
0.15,
|
||||
0.5,
|
||||
// Out to the right and back over the last third of itself.
|
||||
vec![(0.1, 0.5), (0.9, 0.5), (0.65, 0.5)],
|
||||
)]));
|
||||
|
||||
let single = luma_in(&render(&ctx, &once, None), SIZE, SIZE * 3 / 4, SIZE / 2);
|
||||
let crossed = luma_in(&render(&ctx, &twice, None), SIZE, SIZE * 3 / 4, SIZE / 2);
|
||||
|
||||
assert_eq!(
|
||||
single, crossed,
|
||||
"one pass of the brush, however many times the path went over it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Between gestures, though, paint does build up — that is what a flow below
|
||||
/// one is for, and it is the same blend that lets an erase work.
|
||||
#[test]
|
||||
fn two_gestures_at_half_flow_build_up() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let dab = (false, 0.2, 0.5, vec![(0.5, 0.5)]);
|
||||
let once = stack_of(painted(std::slice::from_ref(&dab)));
|
||||
let twice = stack_of(painted(&[dab.clone(), dab]));
|
||||
|
||||
let single = luma_in(&render(&ctx, &once, None), SIZE, SIZE / 2, SIZE / 2);
|
||||
let doubled = luma_in(&render(&ctx, &twice, None), SIZE, SIZE / 2, SIZE / 2);
|
||||
|
||||
assert!(
|
||||
doubled > single,
|
||||
"a second pass should deposit more: {single} then {doubled}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A brush whose dab is an ellipse is not a brush. The radius is a fraction of
|
||||
/// the *shorter* edge, so on a frame twice as wide as it is tall a circle in
|
||||
/// normalised coordinates would come out twice as wide as it is high.
|
||||
#[test]
|
||||
fn a_dab_is_round_on_a_frame_that_is_not_square() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 32;
|
||||
|
||||
let stack = stack_of(painted(&[(false, 0.25, 1.0, vec![(0.5, 0.5)])]));
|
||||
let pixels = render_at(&ctx, &stack, None, W, H);
|
||||
|
||||
let lit = |v: u8| v > 160;
|
||||
let across = (0..W).filter(|&x| lit(luma_in(&pixels, W, x, H / 2))).count();
|
||||
let down = (0..H).filter(|&y| lit(luma_in(&pixels, W, W / 2, y))).count();
|
||||
|
||||
assert!(across > 4 && down > 4, "the dab should exist: {across}x{down}");
|
||||
assert!(
|
||||
across.abs_diff(down) <= 2,
|
||||
"a dab must be as wide as it is tall, got {across} across and {down} down"
|
||||
);
|
||||
}
|
||||
|
||||
/// Hardness is the edge, and the edge is what a brush is judged on. A hard
|
||||
/// brush that faded like a soft one would make the control do nothing anyone
|
||||
/// could see.
|
||||
#[test]
|
||||
fn hardness_decides_how_quickly_the_edge_falls_away() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let edge = |hardness: f32| {
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
layer.begin_stroke(false, 0.4, hardness, 1.0);
|
||||
layer.extend_stroke(0.5, 0.5);
|
||||
layer.end_stroke();
|
||||
|
||||
let pixels = render(&ctx, &stack_of(layer), None);
|
||||
// How many pixels along the centre row are neither fully painted nor
|
||||
// fully clear — the width of the transition. "Fully painted" is read
|
||||
// from the middle of the dab rather than assumed: +2 EV over mid grey
|
||||
// lands wherever the output transform puts it.
|
||||
let solid = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2);
|
||||
(0..SIZE)
|
||||
.filter(|&x| {
|
||||
let v = luma_in(&pixels, SIZE, x, SIZE / 2);
|
||||
v > UNTOUCHED + 8 && v < solid - 8
|
||||
})
|
||||
.count()
|
||||
};
|
||||
|
||||
let soft = edge(0.0);
|
||||
let hard = edge(1.0);
|
||||
assert!(
|
||||
hard < soft,
|
||||
"a hard brush should transition in fewer pixels: hard {hard}, soft {soft}"
|
||||
);
|
||||
assert!(hard <= 4, "and it should be nearly a step, got {hard}");
|
||||
}
|
||||
|
||||
/// The loud failure an unpainted mask can produce: empty inverts to
|
||||
/// everything, so a layer created with invert already set would apply its
|
||||
/// adjustment to the whole photograph before a stroke was made.
|
||||
#[test]
|
||||
fn an_inverted_brush_layer_with_no_strokes_changes_nothing() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
layer.invert = true;
|
||||
let pixels = render(&ctx, &stack_of(layer), None);
|
||||
|
||||
for (x, y) in [(1, 1), (SIZE / 2, SIZE / 2), (SIZE - 2, SIZE - 2)] {
|
||||
let v = luma_in(&pixels, SIZE, x, y);
|
||||
assert!(
|
||||
(120..=136).contains(&v),
|
||||
"an unpainted mask covers nothing, inverted or not; got {v} at {x},{y}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A painted layer and a gradient in one stack must not read each other's
|
||||
/// slice — the brush writes its slot through a different pipeline, which is
|
||||
/// exactly where a slot could be got wrong without either alone noticing.
|
||||
#[test]
|
||||
fn a_brush_layer_and_a_gradient_keep_their_own_slices() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(painted(&[(false, 0.15, 1.0, vec![(0.5, 0.15)])]));
|
||||
|
||||
let mut darken = MaskLayer::new(
|
||||
"m2",
|
||||
MaskSource::Radial {
|
||||
centre: (0.5, 0.85),
|
||||
radii: (0.15, 0.15),
|
||||
angle: 0.0,
|
||||
feather: 0.1,
|
||||
},
|
||||
);
|
||||
darken.set_param("exposure", ParamId("exposure"), -2.0);
|
||||
stack.push(darken);
|
||||
|
||||
let pixels = render(&ctx, &stack, None);
|
||||
let top = luma_in(&pixels, SIZE, SIZE / 2, SIZE * 3 / 20);
|
||||
let bottom = luma_in(&pixels, SIZE, SIZE / 2, SIZE * 17 / 20);
|
||||
|
||||
assert!(top > 180, "the painted dab should have brightened: {top}");
|
||||
assert!(bottom < 100, "the radial should have darkened: {bottom}");
|
||||
}
|
||||
|
||||
/// A neutral edit must render identically whether or not masks are bound —
|
||||
/// otherwise merely *having* the feature would alter every unedited image.
|
||||
#[test]
|
||||
fn an_empty_stack_renders_exactly_as_the_unmasked_path() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let plain = {
|
||||
let source = grey(&ctx);
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
let shader = EditGraph::default_chain().compose();
|
||||
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||
adjust.export_pixels().expect("readback").0
|
||||
};
|
||||
|
||||
let masked = render(&ctx, &MaskStack::new(), None);
|
||||
assert_eq!(plain, masked, "an empty mask stack must be a no-op");
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
//! Clarity and texture, end to end on a real device.
|
||||
//!
|
||||
//! `dr-pipeline`'s tests assert what the composer *generates* — the kernel
|
||||
//! width, the uniforms, which lines of WGSL each node emits. None of that can
|
||||
//! tell whether the two passes compose into an unsharp mask, whether the
|
||||
//! original colour really survives the hand-off from the blur pass to the
|
||||
//! combining one, or whether the halo the soft limit is supposed to bound is
|
||||
//! actually bounded in pixels. Those are questions only a GPU answers.
|
||||
//!
|
||||
//! # Why every measurement is in stops
|
||||
//!
|
||||
//! The controls work on log luminance, and their guarantees are stated in
|
||||
//! stops: an overshoot of at most `gain * threshold`, an effect that is
|
||||
//! symmetric about neutral, a strength that does not depend on how bright the
|
||||
//! subject is. Asserting on 8-bit code values would restate all of that in a
|
||||
//! unit where none of it is true, and would need a fresh magic number for
|
||||
//! every brightness tested. So the pixels are decoded back to linear and
|
||||
//! compared as ratios.
|
||||
//!
|
||||
//! # The test image
|
||||
//!
|
||||
//! A vertical step between two **midtones** rather than between black and
|
||||
//! white. Clarity is tapered to nothing at both ends of the range on purpose
|
||||
//! (see `midtone_weight`), so a 0–255 step is the one edge in the world it is
|
||||
//! designed to leave alone, and a test built on it would measure the taper
|
||||
//! working and call it the feature not working.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::{OpId, ParamId};
|
||||
use dr_pipeline::ops::local_contrast::{Clarity, Texture};
|
||||
use dr_pipeline::{Affects, EditGraph, OutputMode};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
const CLARITY: OpId = OpId("clarity");
|
||||
const TEXTURE: OpId = OpId("texture");
|
||||
const AMOUNT: ParamId = ParamId("amount");
|
||||
|
||||
/// Large enough that texture's kernel — a tenth of clarity's — is still more
|
||||
/// than one pixel wide. At 1024 its sigma is 1.2 px; at 256 it would round to
|
||||
/// a delta and the control would honestly do nothing, which is the behaviour
|
||||
/// `texture_stops_rather_than_lying_when_the_render_is_too_small` covers and
|
||||
/// not the behaviour under test here.
|
||||
const SIZE: u32 = 1024;
|
||||
|
||||
/// The two sides of the step, as sRGB code values.
|
||||
///
|
||||
/// Both well inside the range, and roughly two stops apart — a real edge, of
|
||||
/// the kind that produces the halo this file exists to bound.
|
||||
const DARK: u8 = 90;
|
||||
const BRIGHT: u8 = 175;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
// CI runners and headless machines may have no usable adapter. Skip rather
|
||||
// than fail, exactly as the rest of this crate's device tests do.
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn srgb_decode(v: u8) -> f32 {
|
||||
let e = v as f32 / 255.0;
|
||||
if e <= 0.040_45 {
|
||||
e / 12.92
|
||||
} else {
|
||||
((e + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
|
||||
/// A vertical step from `DARK` to `BRIGHT` at the half-way column.
|
||||
fn step_edge(ctx: &GpuContext, size: u32, tint: [f32; 3]) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..size * size)
|
||||
.flat_map(|i| {
|
||||
let x = i % size;
|
||||
let v = if x < size / 2 { DARK } else { BRIGHT } as f32;
|
||||
[
|
||||
(v * tint[0]).round() as u8,
|
||||
(v * tint[1]).round() as u8,
|
||||
(v * tint[2]).round() as u8,
|
||||
255,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
|
||||
}
|
||||
|
||||
/// One row of the rendered image, as linear luminance-ish red values.
|
||||
fn row(pixels: &[u8], size: u32, y: u32) -> Vec<u8> {
|
||||
(0..size)
|
||||
.map(|x| pixels[((y * size + x) * 4) as usize])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One row as full RGB triples.
|
||||
fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> {
|
||||
(0..size)
|
||||
.map(|x| {
|
||||
let i = ((y * size + x) * 4) as usize;
|
||||
[pixels[i], pixels[i + 1], pixels[i + 2]]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render one graph with its detail stage and read the pixels back.
|
||||
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
pass.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
/// A graph with one of the two controls set and everything else neutral.
|
||||
fn graph_with(op: OpId, amount: f32) -> EditGraph {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(op, AMOUNT, amount);
|
||||
g
|
||||
}
|
||||
|
||||
/// How far a pixel moved, in stops, against the same pixel unedited.
|
||||
fn stops(edited: u8, plain: u8) -> f32 {
|
||||
(srgb_decode(edited).max(1e-6) / srgb_decode(plain).max(1e-6)).log2()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clarity_lifts_local_contrast_and_leaves_the_flat_regions_alone() {
|
||||
// The definition of a local contrast control, as pixels: it must do
|
||||
// something at the edge and *nothing* a long way from it. An operation
|
||||
// that brightened the whole bright plateau would be an exposure slider
|
||||
// with extra steps, and it is the failure a sign error in the base
|
||||
// produces.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||||
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let edited = row(
|
||||
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
let edge = (SIZE / 2) as usize;
|
||||
let reach = Clarity::with_amount(100.0)
|
||||
.kernel(EditGraph::default_chain().render_scale((SIZE, SIZE), (SIZE, SIZE)))
|
||||
as usize;
|
||||
|
||||
// Far outside the kernel's reach the base equals the pixel, the detail
|
||||
// signal is zero, and the output must be the input to the last code value.
|
||||
for x in [0, reach / 2, SIZE as usize - 1 - reach / 2, SIZE as usize - 1] {
|
||||
assert!(
|
||||
edited[x].abs_diff(plain[x]) <= 1,
|
||||
"column {x} moved by {} away from any edge",
|
||||
edited[x].abs_diff(plain[x])
|
||||
);
|
||||
}
|
||||
|
||||
// And at the edge it must do the thing it is for: the bright side lifts,
|
||||
// the dark side drops, which is what "more local contrast" means.
|
||||
assert!(
|
||||
edited[edge] > plain[edge] + 4,
|
||||
"the bright side of the edge did not lift: {} vs {}",
|
||||
edited[edge],
|
||||
plain[edge]
|
||||
);
|
||||
assert!(
|
||||
edited[edge - 1] + 4 < plain[edge - 1],
|
||||
"the dark side of the edge did not drop: {} vs {}",
|
||||
edited[edge - 1],
|
||||
plain[edge - 1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_soft_limit_bounds_the_halo_at_a_hard_edge() {
|
||||
// The single most common way clarity is got wrong, held to a number.
|
||||
//
|
||||
// `t * tanh(d / t)` saturates at `t`, so no pixel may move further than
|
||||
// `gain * threshold` stops however violent the edge — a bound that holds
|
||||
// by construction rather than by tuning, and one this test takes from the
|
||||
// operation itself rather than restating.
|
||||
//
|
||||
// The comparison that gives it meaning is the second assertion: an
|
||||
// unlimited unsharp mask over this edge would move the bright side by
|
||||
// about half the step, which is more than twice as far. That is the
|
||||
// difference between a control and a white glow along the skyline.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||||
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let edited = row(
|
||||
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
let worst = (0..SIZE as usize)
|
||||
.map(|x| stops(edited[x], plain[x]).abs())
|
||||
.fold(0.0f32, f32::max);
|
||||
let bound = Clarity::with_amount(100.0).overshoot_bound();
|
||||
|
||||
// Where the two comparisons below sit, derived rather than observed:
|
||||
//
|
||||
// srgb_decode(175) = 0.4287, srgb_decode(90) = 0.1022
|
||||
// the step is log2(0.4287 / 0.1022) = 2.069 stops
|
||||
// an unlimited mask peaks at half of it = 1.034 stops
|
||||
// the soft limit saturates at = 0.350 stops (`bound`)
|
||||
// the midtone taper then takes about 13% off at 175, so the peak this
|
||||
// test should actually see is near = 0.30 stops
|
||||
//
|
||||
// So 0.30 has to clear the 0.1 floor with room, and fall well under both
|
||||
// 0.35 + slack and 0.6 × 1.034 = 0.62. Every one of those is a bound with
|
||||
// a reason, not a tolerance widened until the test passed.
|
||||
//
|
||||
// A code value's worth of slack: the readback is 8-bit, and a pixel
|
||||
// sitting exactly on the bound quantises either side of it.
|
||||
assert!(
|
||||
worst <= bound + 0.02,
|
||||
"a pixel moved {worst:.3} stops, past the {bound:.3} the soft limit \
|
||||
promises"
|
||||
);
|
||||
|
||||
// Half the step is what an unlimited mask would have produced at the very
|
||||
// edge, since the base there is the mean of the two plateaus.
|
||||
let unlimited = (srgb_decode(BRIGHT) / srgb_decode(DARK)).log2() / 2.0;
|
||||
assert!(
|
||||
worst < unlimited * 0.6,
|
||||
"the limit is not biting: {worst:.3} stops against the {unlimited:.3} \
|
||||
an unlimited unsharp mask would give"
|
||||
);
|
||||
// But it is still a real effect, not a control that does nothing.
|
||||
assert!(worst > 0.1, "clarity moved almost nothing: {worst:.3} stops");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_and_an_export_agree_about_the_effect() {
|
||||
// TRACES: FR-DSP-1 — the decision the radius unit rests on, proved in
|
||||
// pixels rather than in kernel widths.
|
||||
//
|
||||
// Clarity's radius is a fraction of the frame because the control is
|
||||
// compositional: "separate the subject from its background" is a statement
|
||||
// about how much of the picture the subject occupies. If that is right,
|
||||
// the *same edit* rendered at two resolutions must produce an effect of
|
||||
// the same strength covering the same proportion of the frame — which is
|
||||
// exactly what a photographer tuning on screen and exporting at full size
|
||||
// is relying on.
|
||||
//
|
||||
// Had the radius been stated in source pixels, the proxy here would show
|
||||
// half the reach and the export would be a different photograph.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||||
|
||||
// Peak excursion in stops, and how far the effect reaches, as a fraction
|
||||
// of the frame.
|
||||
let measure = |out: u32| -> (f32, f32) {
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, out),
|
||||
out,
|
||||
out / 2,
|
||||
);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let edited = row(
|
||||
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, out),
|
||||
out,
|
||||
out / 2,
|
||||
);
|
||||
|
||||
let moved: Vec<f32> = (0..out as usize)
|
||||
.map(|x| stops(edited[x], plain[x]).abs())
|
||||
.collect();
|
||||
let peak = moved.iter().cloned().fold(0.0f32, f32::max);
|
||||
// The width of the band that moved by more than a tenth of the peak —
|
||||
// a threshold relative to the effect, so it means the same thing at
|
||||
// both sizes.
|
||||
let touched = moved.iter().filter(|m| **m > peak * 0.1).count();
|
||||
(peak, touched as f32 / out as f32)
|
||||
};
|
||||
|
||||
let (proxy_peak, proxy_reach) = measure(SIZE / 2);
|
||||
let (export_peak, export_reach) = measure(SIZE);
|
||||
|
||||
assert!(
|
||||
(proxy_peak - export_peak).abs() < 0.03,
|
||||
"the same edit is {proxy_peak:.3} stops on the proxy and \
|
||||
{export_peak:.3} in the export"
|
||||
);
|
||||
assert!(
|
||||
(proxy_reach - export_reach).abs() < 0.02,
|
||||
"the effect covers {proxy_reach:.3} of the proxy and {export_reach:.3} \
|
||||
of the export; a radius tuned on screen must land in the file"
|
||||
);
|
||||
// And it is a real effect at both sizes, not two flat images agreeing.
|
||||
assert!(
|
||||
proxy_peak > 0.1 && proxy_reach > 0.02,
|
||||
"{proxy_peak:.3} stops over {proxy_reach:.3} of the proxy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_acts_at_a_finer_scale_than_clarity() {
|
||||
// The whole reason there are two nodes. If the two controls ever reach the
|
||||
// same distance from an edge, the second slider has become a duplicate of
|
||||
// the first and a photographer setting both is setting one thing twice.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||||
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
let reach = |op: OpId| -> usize {
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let edited = row(
|
||||
&render(&mut pass, &graph_with(op, 100.0), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
// How many columns moved by more than a code value — the honest
|
||||
// measure of "how far from the edge does this control reach".
|
||||
(0..SIZE as usize)
|
||||
.filter(|&x| edited[x].abs_diff(plain[x]) > 1)
|
||||
.count()
|
||||
};
|
||||
|
||||
let coarse = reach(CLARITY);
|
||||
let fine = reach(TEXTURE);
|
||||
assert!(fine > 0, "texture did nothing at all");
|
||||
assert!(
|
||||
coarse > fine * 4,
|
||||
"clarity reaches {coarse} columns and texture {fine}; these are not \
|
||||
separable scales"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clarity_moves_luminance_without_moving_hue() {
|
||||
// The third halo decision, in pixels. The gain is applied as a scale on
|
||||
// the whole triple, so chromaticity is untouched; boosting the channels
|
||||
// independently would put a *coloured* fringe along every edge, arriving
|
||||
// from a control the photographer reads as contrast.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
// A strongly tinted step, so a per-channel mask would show plainly.
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 0.55, 0.25]);
|
||||
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row_rgb(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let edited = row_rgb(
|
||||
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
// Compare in linear light, where a scale is a scale. The two channel
|
||||
// ratios together fix the chromaticity, so holding both fixes the colour.
|
||||
let edge = (SIZE / 2) as usize;
|
||||
for x in [edge, edge + 1, edge + 4, edge - 1, edge - 4] {
|
||||
let ratio = |p: [u8; 3], i: usize| srgb_decode(p[i]) / srgb_decode(p[0]).max(1e-6);
|
||||
for channel in [1, 2] {
|
||||
let before = ratio(plain[x], channel);
|
||||
let after = ratio(edited[x], channel);
|
||||
assert!(
|
||||
(after - before).abs() < 0.02,
|
||||
"column {x} channel {channel}: chromaticity moved from \
|
||||
{before:.4} to {after:.4} — that is a coloured fringe"
|
||||
);
|
||||
}
|
||||
}
|
||||
// And the effect was actually applied here, or the assertion above is
|
||||
// vacuous.
|
||||
assert!(edited[edge][0].abs_diff(plain[edge][0]) > 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_clarity_softens_the_surface_without_dissolving_the_edge() {
|
||||
// The soft limit earns its keep in both directions. An unlimited mask at
|
||||
// −100 subtracts the whole detail signal and turns every edge to mud;
|
||||
// limited, it removes at most the threshold, so modelling softens and real
|
||||
// edges stand.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||||
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let softened = row(
|
||||
&render(&mut pass, &graph_with(CLARITY, -100.0), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
let edge = (SIZE / 2) as usize;
|
||||
// The sign is the other way round from the positive case: the bright side
|
||||
// of the edge comes down and the dark side comes up.
|
||||
assert!(
|
||||
softened[edge] + 3 < plain[edge],
|
||||
"negative clarity did not soften: {} vs {}",
|
||||
softened[edge],
|
||||
plain[edge]
|
||||
);
|
||||
|
||||
// But the step itself survives. Measured in stops across the edge, so the
|
||||
// claim is about contrast and not about code values.
|
||||
let step_of = |r: &[u8]| (srgb_decode(r[edge]) / srgb_decode(r[edge - 1])).log2();
|
||||
let before = step_of(&plain);
|
||||
let after = step_of(&softened);
|
||||
assert!(
|
||||
after > before * 0.55,
|
||||
"the edge dissolved: {after:.3} stops left of {before:.3}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_controls_cost_the_edit_nothing() {
|
||||
// Both nodes are in the default chain, and both are the widest kernels in
|
||||
// the pipeline. An unedited photograph must render through the single
|
||||
// fused dispatch it always did — no detail pass, no intermediate texture,
|
||||
// and byte-identical pixels.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, 128, [1.0, 1.0, 1.0]);
|
||||
let graph = EditGraph::default_chain();
|
||||
|
||||
assert_eq!(
|
||||
graph.compose_for(ColourSpace::Srgb).output_mode,
|
||||
OutputMode::Encoded,
|
||||
"a neutral detail operation must not change how the fused pass ends"
|
||||
);
|
||||
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
render(&mut pass, &graph, &source, 128);
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
assert_eq!(pass.detail_dispatches(), 0);
|
||||
assert_eq!(pass.detail_allocations(), 0, "nothing was allocated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() {
|
||||
// TRACES: FR-DEV-3d. Clarity is `Affects::Detail`, so the fused colour
|
||||
// pass's result is still valid while the slider moves — which for a
|
||||
// hundred-tap kernel is the difference between an interactive control and
|
||||
// a slideshow. Invisible in the output by construction, so a dispatch
|
||||
// counter is the only thing that can see it.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, 256, [1.0, 1.0, 1.0]);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut graph = graph_with(CLARITY, 40.0);
|
||||
render(&mut pass, &graph, &source, 256);
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
assert_eq!(pass.detail_dispatches(), 2, "a separable mask is two passes");
|
||||
let pipelines = pass.cached_detail_pipelines();
|
||||
|
||||
for amount in [50.0, 60.0, 70.0] {
|
||||
graph.set_param(CLARITY, AMOUNT, amount);
|
||||
render(&mut pass, &graph, &source, 256);
|
||||
}
|
||||
assert_eq!(
|
||||
pass.colour_dispatches(),
|
||||
1,
|
||||
"the fused colour pass re-ran for a change it does not depend on"
|
||||
);
|
||||
assert_eq!(pass.detail_dispatches(), 8);
|
||||
assert_eq!(
|
||||
pass.cached_detail_pipelines(),
|
||||
pipelines,
|
||||
"an amount is a uniform, not a shader"
|
||||
);
|
||||
|
||||
// Turning on the other control adds its own pair, and only its own pair.
|
||||
graph.set_param(TEXTURE, AMOUNT, 40.0);
|
||||
render(&mut pass, &graph, &source, 256);
|
||||
assert_eq!(pass.detail_dispatches(), 12);
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_controls_stack_without_overwriting_each_other() {
|
||||
// Four passes through one ping-pong, with the scratch lane changing hands
|
||||
// half way. If clarity's combining pass left the colour where its blur
|
||||
// pass had put it — or if texture's blur overwrote the colour rather than
|
||||
// the lane — the result would be a blurred image rather than a sharpened
|
||||
// one, which is loud rather than subtle.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||||
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
|
||||
let mut graph = graph_with(CLARITY, 80.0);
|
||||
graph.set_param(TEXTURE, AMOUNT, 80.0);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let both = row(&render(&mut pass, &graph, &source, SIZE), SIZE, SIZE / 2);
|
||||
assert_eq!(pass.detail_dispatches(), 4);
|
||||
|
||||
let edge = (SIZE / 2) as usize;
|
||||
// Both sides of the edge move the way local contrast moves them...
|
||||
assert!(both[edge] > plain[edge] + 4);
|
||||
assert!(both[edge - 1] + 4 < plain[edge - 1]);
|
||||
// ...and the plateaus are untouched, which a stray blur would not leave.
|
||||
assert!(both[0].abs_diff(plain[0]) <= 1);
|
||||
assert!(both[SIZE as usize - 1].abs_diff(plain[SIZE as usize - 1]) <= 1);
|
||||
|
||||
// Stacked, they must reach further than either alone — the coarse control
|
||||
// still working at its own scale rather than being overwritten by the fine
|
||||
// one running after it.
|
||||
let mut clarity_only = AdjustPass::new(&ctx);
|
||||
let coarse = row(
|
||||
&render(&mut clarity_only, &graph_with(CLARITY, 80.0), &source, SIZE),
|
||||
SIZE,
|
||||
SIZE / 2,
|
||||
);
|
||||
assert!(
|
||||
both[edge] >= coarse[edge],
|
||||
"adding texture undid clarity: {} against {}",
|
||||
both[edge],
|
||||
coarse[edge]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_contributes_nothing_where_its_scale_does_not_exist() {
|
||||
// Unlike the acutance family this is not an approximation being hidden. A
|
||||
// two-pixel surface structure is not present in a 128-pixel rendering of
|
||||
// the frame, so the honest answer is no pass at all — and clarity, a
|
||||
// hundred times wider, still runs, which is what a thumbnail should show.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = step_edge(&ctx, 512, [1.0, 1.0, 1.0]);
|
||||
|
||||
let mut graph = graph_with(TEXTURE, 100.0);
|
||||
|
||||
// Asserted on the composed chain rather than on a dispatch counter,
|
||||
// because what is interesting here is not how many dispatches ran but
|
||||
// that texture contributed no *kernel* to them.
|
||||
//
|
||||
// This assertion used to require an empty chain, and recorded the empty
|
||||
// chain as a gap in the seam: `compose_full` decides whether the fused
|
||||
// pass hands on linear working values from `is_active()`, which has no
|
||||
// `RenderScale` to consult, while `compose_detail` decides what to
|
||||
// dispatch from the kernel it can actually draw at this scale. When a
|
||||
// detail operation was active and its kernel rounded away, the two
|
||||
// disagreed, `render_detailed` found nothing to run, fell through to
|
||||
// `render_masked`, and was rejected for handing a linear-working shader
|
||||
// to the plain path — so texture alone on a thumbnail did not render.
|
||||
//
|
||||
// The seam was closed where that note said it would have to be, at the
|
||||
// composition boundary: `compose_detail` now emits a bodyless
|
||||
// `detail/resolve` pass in exactly this case, which reads only the pixel
|
||||
// it writes and performs the output transform the fused pass declined to
|
||||
// do. So the chain is no longer empty — it carries precisely the one pass
|
||||
// that finishes the render and no kernel at all, which is the honest
|
||||
// description of "a two-pixel surface structure is not present in a
|
||||
// 128-pixel rendering".
|
||||
let scale = graph.render_scale(source.size(), (128, 128));
|
||||
let composed = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
assert_eq!(
|
||||
composed.len(),
|
||||
1,
|
||||
"the chain must carry the resolve pass and nothing else"
|
||||
);
|
||||
assert_eq!(composed.passes[0].label, "detail/resolve");
|
||||
assert_eq!(
|
||||
composed.radius(),
|
||||
0,
|
||||
"texture claimed a kernel it cannot draw"
|
||||
);
|
||||
|
||||
// With clarity on as well the edit is renderable again, and the dispatch
|
||||
// count says what the assertion above says: two passes, not four. Texture
|
||||
// is active, and contributes nothing.
|
||||
graph.set_param(CLARITY, AMOUNT, 100.0);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
render(&mut pass, &graph, &source, 128);
|
||||
assert_eq!(
|
||||
pass.detail_dispatches(),
|
||||
2,
|
||||
"clarity survives a thumbnail, and texture added nothing beside it"
|
||||
);
|
||||
|
||||
// And texture comes back, exactly, as soon as the view is large enough to
|
||||
// hold it — no separate path, no fade, just the kernel resolving again.
|
||||
let mut zoomed = AdjustPass::new(&ctx);
|
||||
render(&mut zoomed, &graph_with(TEXTURE, 100.0), &source, 1024);
|
||||
assert_eq!(zoomed.detail_dispatches(), 2);
|
||||
}
|
||||