Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d3083cc32 |
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"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/\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
# 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
|
||||
@@ -1,170 +0,0 @@
|
||||
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"
|
||||
@@ -1,378 +0,0 @@
|
||||
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
|
||||
|
||||
# This job filled the runner's disk and died mid-link with "No space left
|
||||
# on device" — LLVM reporting an IO failure on its output stream, which
|
||||
# reads like a compiler crash and is not one.
|
||||
#
|
||||
# `target/debug` was 24 GB against `target/release`'s 2.6 GB: 15 GB of it
|
||||
# debug info in `debug/deps`, 3.6 GB incremental state. Neither earns its
|
||||
# space here. Nothing attaches a debugger to a CI run, and incremental
|
||||
# compilation exists to make the *second* build in a working tree fast,
|
||||
# which is not a thing a fresh checkout has. Turning both off is the
|
||||
# standard CI setting rather than a trick.
|
||||
#
|
||||
# Measured on this workspace: the same `cargo test --workspace --no-run`
|
||||
# tree goes from 24 GB to 3.3 GB, `debug/deps` from 15 GB to 2.8 GB.
|
||||
#
|
||||
# Backtraces still name functions without debug info; they lose file and
|
||||
# line numbers. If a test failure ever needs those, drop DEBUG to 1
|
||||
# (line-tables-only) rather than back to 2.
|
||||
#
|
||||
# This is a mitigation, not a fix. If the runner is full of anything other
|
||||
# than this job's own output, it will still be full afterwards.
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_PROFILE_DEV_DEBUG: 0
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The model, which is in LFS and is not optional.
|
||||
#
|
||||
# `core/dr-segment/models/*.onnx` is tracked in LFS (.gitattributes), so a
|
||||
# plain checkout writes a ~130-byte pointer where 11 MB should be, and
|
||||
# `dr-segment`'s build script panics by design rather than embedding a
|
||||
# pointer and failing at inference. That failure reads like a broken build
|
||||
# instead of a missing fetch, which is how it went unnoticed.
|
||||
#
|
||||
# Not `lfs: true` on the checkout above, and no `Authorization` header
|
||||
# here either. Both install a blanket header for every request to this
|
||||
# host, and the object download is the one request that already carries
|
||||
# one: `git lfs pull` asks `/info/lfs/objects/batch` first, and Gitea
|
||||
# answers with a short-lived `Bearer` JWT scoped to that object. git-lfs
|
||||
# then sends the JWT *and* the configured header, and two `Authorization`
|
||||
# headers is a 400 from Gitea — reported as
|
||||
# LFS: Client error: .../info/lfs/objects/<oid>
|
||||
# one step after the batch call that had just succeeded, which reads like
|
||||
# a rejected credential rather than a duplicated one. A lone token header
|
||||
# is understood fine; it is only the collision that fails.
|
||||
#
|
||||
# So: strip the headers and hand the token to git-lfs as an ordinary
|
||||
# credential instead. It authenticates the batch call and leaves the
|
||||
# per-object JWT untouched. Gitea authenticates on the password, so the
|
||||
# username is a placeholder. Nothing later in this job talks to the
|
||||
# remote, so dropping checkout's header costs us nothing.
|
||||
#
|
||||
# `continue-on-error` deliberately: if this cannot authenticate, the build
|
||||
# below still runs and fails with the build script's own message, which
|
||||
# names the real problem. A checkout that dies here says nothing.
|
||||
- name: Fetch the segmentation model
|
||||
continue-on-error: true
|
||||
env:
|
||||
LFS_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
run: |
|
||||
set -e
|
||||
git lfs install --local
|
||||
git config --local --get-regexp '^http\..*extraheader$' \
|
||||
| cut -d' ' -f1 | sort -u \
|
||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull
|
||||
ls -l core/dr-segment/models/
|
||||
|
||||
- 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"
|
||||
|
||||
# Free space before and after the expensive steps, so a repeat of the
|
||||
# disk exhaustion above is one line to diagnose instead of a puzzling
|
||||
# LLVM error.
|
||||
- name: Disk before
|
||||
run: df -h /workspace 2>/dev/null || df -h .
|
||||
|
||||
- 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
|
||||
|
||||
- name: Disk after
|
||||
if: always()
|
||||
run: df -h /workspace 2>/dev/null || df -h .
|
||||
|
||||
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
|
||||
|
||||
# The model, which is in LFS and is not optional.
|
||||
#
|
||||
# `core/dr-segment/models/*.onnx` is tracked in LFS (.gitattributes), so a
|
||||
# plain checkout writes a ~130-byte pointer where 11 MB should be, and
|
||||
# `dr-segment`'s build script panics by design rather than embedding a
|
||||
# pointer and failing at inference. That failure reads like a broken build
|
||||
# instead of a missing fetch, which is how it went unnoticed.
|
||||
#
|
||||
# Not `lfs: true` on the checkout above, and no `Authorization` header
|
||||
# here either. Both install a blanket header for every request to this
|
||||
# host, and the object download is the one request that already carries
|
||||
# one: `git lfs pull` asks `/info/lfs/objects/batch` first, and Gitea
|
||||
# answers with a short-lived `Bearer` JWT scoped to that object. git-lfs
|
||||
# then sends the JWT *and* the configured header, and two `Authorization`
|
||||
# headers is a 400 from Gitea — reported as
|
||||
# LFS: Client error: .../info/lfs/objects/<oid>
|
||||
# one step after the batch call that had just succeeded, which reads like
|
||||
# a rejected credential rather than a duplicated one. A lone token header
|
||||
# is understood fine; it is only the collision that fails.
|
||||
#
|
||||
# So: strip the headers and hand the token to git-lfs as an ordinary
|
||||
# credential instead. It authenticates the batch call and leaves the
|
||||
# per-object JWT untouched. Gitea authenticates on the password, so the
|
||||
# username is a placeholder. Nothing later in this job talks to the
|
||||
# remote, so dropping checkout's header costs us nothing.
|
||||
#
|
||||
# `continue-on-error` deliberately: if this cannot authenticate, the build
|
||||
# below still runs and fails with the build script's own message, which
|
||||
# names the real problem. A checkout that dies here says nothing.
|
||||
- name: Fetch the segmentation model
|
||||
continue-on-error: true
|
||||
env:
|
||||
LFS_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
run: |
|
||||
set -e
|
||||
git lfs install --local
|
||||
git config --local --get-regexp '^http\..*extraheader$' \
|
||||
| cut -d' ' -f1 | sort -u \
|
||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull
|
||||
ls -l core/dr-segment/models/
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/opt/cargo/registry
|
||||
target-android
|
||||
key: android-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
# A fast gate on the crates most likely to break the cross-compile, run
|
||||
# before the expensive part. It is `cargo check`, so it type-checks
|
||||
# without linking and returns in a fraction of the time the step below
|
||||
# takes.
|
||||
#
|
||||
# Not a statement that only these crates cross-compile — `darkroom-android`
|
||||
# and the whole UI stack beneath it build for aarch64 too, which is what
|
||||
# the API-level step below does. This one exists to fail fast and name a
|
||||
# smaller suspect when it does.
|
||||
- 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
|
||||
# `darkroom-android`, not a core crate: this step reads the API level
|
||||
# out of a *linked* object, and only that crate produces one. It is
|
||||
# the workspace's single `crate-type = ["cdylib"]`; a library crate
|
||||
# builds an rlib, which is an archive of object files that no linker
|
||||
# has yet touched and that `file` therefore has nothing to say about.
|
||||
# Asking for `-p dr-gpu` here could only ever reach the "no aarch64
|
||||
# .so was produced" branch below, whatever the linker did.
|
||||
#
|
||||
# It is also the honest artefact to check: the .so this names is the
|
||||
# one that ships in the APK, so the API level verified here is the
|
||||
# API level a device will refuse to install against.
|
||||
cargo ndk -t arm64-v8a -o target-android/jniLibs \
|
||||
build -p darkroom-android --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
|
||||
|
||||
# The APK itself, so a run leaves something installable behind rather
|
||||
# than only the knowledge that it would have linked. The assembly is
|
||||
# `docker/android/assemble-apk.sh`, shared with `package.sh` so the file
|
||||
# a device gets from `package.sh --install` and the file published here
|
||||
# are built by the same code — see that script's header.
|
||||
#
|
||||
# `KEYSTORE` deliberately points at a throwaway directory instead of its
|
||||
# default under `target-android`: that directory is what `actions/cache`
|
||||
# restores and saves, and a signing key has no business in a build cache
|
||||
# or in anything this job uploads. A fresh debug key per run is the right
|
||||
# trade for an artefact whose purpose is getting the app onto a test
|
||||
# device; nothing upgrades in place over it, which is the one thing a
|
||||
# stable key would buy.
|
||||
- name: Package the APK
|
||||
env:
|
||||
CARGO_TARGET_DIR: target-android
|
||||
# Absent secrets mean a debug signature, which is what a fork or a
|
||||
# branch build should get. Set all three (see docs/android-signing.md)
|
||||
# and the same job produces a release-signed APK instead.
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
run: |
|
||||
set -e
|
||||
KEYDIR="$(mktemp -d)"
|
||||
chmod 700 "$KEYDIR"
|
||||
trap 'rm -rf "$KEYDIR"' EXIT
|
||||
|
||||
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||
# The keystore reaches the runner base64-encoded because a secret
|
||||
# is a string. It is written under a 0700 mktemp directory, never
|
||||
# into the workspace: `target-android` is what actions/cache saves,
|
||||
# and the upload step globs the workspace.
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$KEYDIR/release.keystore"
|
||||
export KEYSTORE="$KEYDIR/release.keystore"
|
||||
else
|
||||
# Not an error. Unset the rest so assemble-apk.sh takes its debug
|
||||
# path cleanly rather than seeing a half-configured release one.
|
||||
export KEYSTORE="$KEYDIR/debug.keystore"
|
||||
unset KEYSTORE_PASS KEY_PASS KEY_ALIAS
|
||||
fi
|
||||
|
||||
REPO="$PWD" TARGET_DIR="$PWD/target-android" \
|
||||
bash docker/android/assemble-apk.sh
|
||||
|
||||
# v3, not v4. v4 is untested against this Gitea and its runner; v3 is
|
||||
# what JellyTau uploads its APK with on this same runner, so it is the
|
||||
# version known to work here rather than the version that ought to.
|
||||
#
|
||||
# `if-no-files-found: error` because the failure this guards against is
|
||||
# a green run with an empty artefact list, which reads as success until
|
||||
# somebody goes looking for the file.
|
||||
- name: Upload the APK
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: darkroom-arm64-v8a-apk
|
||||
path: target-android/apk/darkroom.apk
|
||||
if-no-files-found: error
|
||||
|
||||
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
|
||||
@@ -1,112 +0,0 @@
|
||||
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
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
|
||||
git lfs post-checkout "$@"
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
|
||||
git lfs post-commit "$@"
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
|
||||
git lfs post-merge "$@"
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Keep docs/traceability.md in step with the tags in the tree.
|
||||
#
|
||||
# The gate regenerates the matrix in CI and fails if the result differs from
|
||||
# what is committed. That is the right check — a matrix that disagrees with the
|
||||
# tree is worse than none, because it is read as current — but it fails *after*
|
||||
# a push, on a commit that is otherwise fine, and it has now done so on six
|
||||
# commits in a row because adding a `TRACES:` tag and regenerating the matrix
|
||||
# are two actions and only the first is on anyone's mind.
|
||||
#
|
||||
# So it happens here instead, where the tags are being changed.
|
||||
#
|
||||
# Only when something that can carry a tag is staged: a commit touching
|
||||
# workflows, packaging or the matrix itself pays nothing.
|
||||
set -euo pipefail
|
||||
|
||||
staged="$(git diff --cached --name-only --diff-filter=ACMR)"
|
||||
if ! grep -qE '\.(rs|slint|yaml|md)$' <<< "${staged}"; then
|
||||
exit 0
|
||||
fi
|
||||
# The matrix is generated from the tree, so regenerating it because it was
|
||||
# itself edited would be circular.
|
||||
if [ "$(tr -d '[:space:]' <<< "${staged}")" = "docs/traceability.md" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
repo="$(git rev-parse --show-toplevel)"
|
||||
cd "${repo}"
|
||||
|
||||
# Quiet unless it has something to say. A hook that prints on every commit is
|
||||
# a hook people start passing --no-verify to.
|
||||
if ! cargo run -q -p traceability -- report >/dev/null 2>&1; then
|
||||
echo "pre-commit: could not run the traceability report; leaving the matrix alone" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! git diff --quiet -- docs/traceability.md; then
|
||||
git add docs/traceability.md
|
||||
echo "pre-commit: regenerated docs/traceability.md and staged it"
|
||||
fi
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
|
||||
git lfs pre-push "$@"
|
||||
@@ -1,18 +0,0 @@
|
||||
/target
|
||||
/target-android
|
||||
Cargo.lock.bak
|
||||
*.log
|
||||
|
||||
# makepkg build products. `packaging/PKGBUILD` and the .desktop entry are
|
||||
# sources and belong in the tree; everything makepkg derives from them does
|
||||
# not — `pkg/` and `src/` are staging directories it recreates on every run,
|
||||
# and the package itself is 33 MB of compiled output.
|
||||
/packaging/pkg/
|
||||
/packaging/src/
|
||||
/packaging/*.pkg.tar.*
|
||||
/packaging/*.log
|
||||
|
||||
# Cached upstream film profiles, re-fetchable with
|
||||
# tools/film-profiles/convert.py --fetch. Not source: the converted
|
||||
# profiles in core/dr-film/profiles are.
|
||||
tools/film-profiles/upstream/
|
||||
@@ -1,214 +0,0 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"core/dr-types",
|
||||
"core/dr-catalog",
|
||||
"core/dr-thumbs",
|
||||
"core/dr-decode",
|
||||
"core/dr-export",
|
||||
"core/dr-film",
|
||||
"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.7.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-film = { path = "core/dr-film" }
|
||||
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
|
||||
@@ -1,48 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,34 +0,0 @@
|
||||
[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 = []
|
||||
@@ -1,71 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 518 B |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 343 B |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 680 B |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 1005 B |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 151 KiB |
@@ -1,63 +0,0 @@
|
||||
//! 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:#}");
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
[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 = []
|
||||
@@ -1,28 +0,0 @@
|
||||
//! 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)?;
|
||||
|
||||
// Skip Rust's normal static/thread-local teardown on the way out: a
|
||||
// background zbus/keyring connection opened by dr_ui::launch_ui can
|
||||
// still be alive here, and unwinding through it races its async-io
|
||||
// reactor thread, panicking with "thread local ... during or after
|
||||
// destruction" when the window is closed.
|
||||
std::process::exit(0);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
[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
|
||||
@@ -1,111 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -1,986 +0,0 @@
|
||||
//! 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"[..])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//! 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),
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
//! 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.
|
||||
///
|
||||
/// # Chosen by how many bars it produces, not by fixed cut-offs
|
||||
///
|
||||
/// This used to be four thresholds on the span, which reads sensibly and
|
||||
/// behaves badly under zoom. Each zoom step halves the span, so the bar
|
||||
/// count halves with it until a threshold is crossed — a fifteen-year
|
||||
/// library went 15 bars, 8, then 46, 23, 11, and finally *6*. Zooming in
|
||||
/// made the picture coarser, which is the opposite of what zooming is for.
|
||||
///
|
||||
/// So the choice is made on the axis's terms: of the four bucket sizes,
|
||||
/// take the one whose bar count comes nearest [`Self::TARGET_BARS`]. The
|
||||
/// count then stays in the same neighbourhood at every zoom level, and
|
||||
/// each step in genuinely shows finer structure rather than the same
|
||||
/// structure drawn wider.
|
||||
///
|
||||
/// Nearest in *ratio*, not in difference: the counts available for a given
|
||||
/// span are orders of magnitude apart — a span is either about 4 years or
|
||||
/// about 48 months — and on a linear measure the larger count always looks
|
||||
/// further away, which would bias every choice towards too few bars.
|
||||
pub fn for_span(seconds: i64) -> Self {
|
||||
Self::for_bucket(seconds.max(1) / Self::TARGET_BARS)
|
||||
}
|
||||
|
||||
/// The calendar unit nearest a bucket of `seconds`, for *labelling* one.
|
||||
///
|
||||
/// Split out from [`Self::for_span`] because the axis no longer buckets by
|
||||
/// calendar unit at all — it divides the visible span into a fixed number
|
||||
/// of equal bins (see `LibrarySettings::timeline_bars`). What is still
|
||||
/// wanted is the unit a bin is closest to, so a bin of about a day is
|
||||
/// labelled as a date and one of about a year as a year. Asked directly
|
||||
/// rather than derived from the span, because the bin count is now the
|
||||
/// user's rather than this module's target.
|
||||
pub fn for_bucket(seconds: i64) -> Self {
|
||||
let seconds = seconds.max(1) as f64;
|
||||
// Finest first, so that when two options are equally far from the
|
||||
// target the finer one wins: `min_by` keeps the first minimum it saw,
|
||||
// and more detail is the better failure.
|
||||
[
|
||||
Granularity::Hour,
|
||||
Granularity::Day,
|
||||
Granularity::Month,
|
||||
Granularity::Year,
|
||||
]
|
||||
.into_iter()
|
||||
.min_by(|a, b| {
|
||||
let cost = |g: Granularity| {
|
||||
// How far off, measured multiplicatively: twice as long and
|
||||
// half as long are equally wrong.
|
||||
//
|
||||
// Deliberately not clamped. A bucket shorter than the unit
|
||||
// scores *worse* the coarser the unit, which is what makes an
|
||||
// hour of photographs pick hourly bars instead of every option
|
||||
// tying at "one bucket" and the coarsest winning.
|
||||
(seconds / g.approx_seconds() as f64).ln().abs()
|
||||
};
|
||||
cost(*a)
|
||||
.partial_cmp(&cost(*b))
|
||||
// Ties cannot arise from real spans, but a NaN would; falling
|
||||
// back to the coarser option keeps the axis drawable.
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.unwrap_or(Granularity::Day)
|
||||
}
|
||||
|
||||
/// How many bars the timeline wants across its axis.
|
||||
///
|
||||
/// Not a hard count — the bucket sizes are calendar units, so the actual
|
||||
/// number lands where the calendar puts it. It is the figure the choice
|
||||
/// aims at: enough bars that a busy fortnight is visibly busier than a
|
||||
/// quiet one, few enough that each is wide enough to hit with a finger.
|
||||
const TARGET_BARS: i64 = 40;
|
||||
|
||||
/// Nominal length of one bucket, for choosing between them.
|
||||
///
|
||||
/// Approximate on purpose: months and years vary and it does not matter
|
||||
/// here, because this only ranks four options that are a factor of ~12 or
|
||||
/// ~30 apart. The exact boundaries come from `strftime` on the real dates.
|
||||
fn approx_seconds(self) -> i64 {
|
||||
const DAY: i64 = 86_400;
|
||||
match self {
|
||||
Granularity::Year => 365 * DAY,
|
||||
Granularity::Month => 30 * DAY,
|
||||
Granularity::Day => DAY,
|
||||
Granularity::Hour => 3600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
// Chosen by how many bars it makes, not by fixed cut-offs — see
|
||||
// `for_span`. Ten years of yearly bars is ten bars, which says almost
|
||||
// nothing about a library; monthly is 122, which is a shape.
|
||||
assert_eq!(Granularity::for_span(10 * 365 * DAY), Granularity::Month);
|
||||
assert_eq!(Granularity::for_span(120 * DAY), Granularity::Day);
|
||||
assert_eq!(Granularity::for_span(10 * DAY), Granularity::Day);
|
||||
assert_eq!(Granularity::for_span(3600), Granularity::Hour);
|
||||
|
||||
// The property the target exists for: zooming in never coarsens the
|
||||
// axis. Under the old thresholds a fifteen-year library went 15 bars,
|
||||
// then 8, then 46, 23, 11 — finer spans drawn with wider bars.
|
||||
let mut span = 15 * 365 * DAY;
|
||||
let mut previous = Granularity::for_span(span).approx_seconds();
|
||||
for _ in 0..10 {
|
||||
span /= 2;
|
||||
let bucket = Granularity::for_span(span).approx_seconds();
|
||||
assert!(
|
||||
bucket <= previous,
|
||||
"halving the span to {span}s coarsened the bucket \
|
||||
from {previous}s to {bucket}s"
|
||||
);
|
||||
previous = bucket;
|
||||
}
|
||||
|
||||
// And a span shorter than any bucket still picks the finest, rather
|
||||
// than every option tying at one bar and the coarsest winning.
|
||||
assert_eq!(Granularity::for_span(60), Granularity::Hour);
|
||||
assert_eq!(Granularity::for_span(1), 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");
|
||||
}
|
||||
}
|
||||
@@ -1,514 +0,0 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -1,733 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -1,991 +0,0 @@
|
||||
//! 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 = 7;
|
||||
|
||||
/// 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()?;
|
||||
}
|
||||
if from < 7 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V7)?;
|
||||
tx.pragma_update(None, "user_version", 7)?;
|
||||
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.
|
||||
///
|
||||
/// V7 creates an object and is still excluded, which is the one exception to
|
||||
/// that rule and not an oversight: it indexes `shadowed_by` and `trashed_at`,
|
||||
/// the very columns V2 through V5 add and this function leaves out, so
|
||||
/// creating it over there would fail on columns that are not there. Nothing is
|
||||
/// lost by its absence — it exists to make the *grid* page quickly, and the
|
||||
/// grid never reads across an 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-4 | NFR-P5
|
||||
/// The order the grid reads in, as an index.
|
||||
///
|
||||
/// # What this is for
|
||||
///
|
||||
/// Every window the grid loads is `ORDER BY ... LIMIT n OFFSET k`, and without
|
||||
/// an index that matches the ordering SQLite answers it by sorting the whole
|
||||
/// library into a temp b-tree and then discarding the first `k` rows. Measured
|
||||
/// on 24,000 images at offset 20,000, one window read cost 15 ms — a frame
|
||||
/// budget of 16.7 ms, spent inside the scroll handler, several times per
|
||||
/// screenful. That is the jitter.
|
||||
///
|
||||
/// With this index the same read is a walk along it: 0.36 ms.
|
||||
///
|
||||
/// # Why the shape is what it is
|
||||
///
|
||||
/// `captured_at IS NULL` leads, because [`crate::library`]'s `GRID_ORDER` does
|
||||
/// — undated images sort last, and an ordinary index on `captured_at` cannot
|
||||
/// answer that, since the expression is not a column. SQLite indexes
|
||||
/// expressions, so it is spelled out here exactly as the query spells it; the
|
||||
/// two must stay identical or the planner silently falls back to sorting and
|
||||
/// the cost comes back with no other symptom.
|
||||
///
|
||||
/// `source_ref` is included because it breaks ties in the same ordering, and an
|
||||
/// index that stopped at `captured_at` would leave a sort for the ties.
|
||||
///
|
||||
/// # Partial, on the same predicate the grid filters by
|
||||
///
|
||||
/// The grid never lists shadowed or trashed rows, so an index carrying them
|
||||
/// would be larger than the question ever asks about, and — more to the point —
|
||||
/// a partial index is only usable when its `WHERE` is implied by the query's,
|
||||
/// which is what makes this one apply to the grid's reads and to nothing else.
|
||||
///
|
||||
/// # It does not cover the rating filter or a collection scope
|
||||
///
|
||||
/// Both narrow the walk rather than reorder it, so the index still supplies the
|
||||
/// ordering and SQLite tests the extra predicate per row. That is the cheap
|
||||
/// direction: the expensive part was never the filtering, it was the sort.
|
||||
const V7: &str = r#"
|
||||
CREATE INDEX images_grid_order
|
||||
ON images(captured_at IS NULL, captured_at, source_ref)
|
||||
WHERE shadowed_by IS NULL AND trashed_at IS NULL;
|
||||
"#;
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
@@ -1,636 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[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
|
||||
@@ -1,52 +0,0 @@
|
||||
//! 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
//! 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}");
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
//! 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])
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
# 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]
|
||||
@@ -1,752 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
[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
|
||||
@@ -1,211 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! 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),
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
//! 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
//! 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
//! 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "dr-film"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
# Isolated from dr-pipeline for the same reason dr-lens is: that crate has no
|
||||
# dependencies so its codegen stays testable without a device (ARCH §6.5a), and
|
||||
# a YAML parser plus the stock profiles do not belong in it. The pipeline
|
||||
# consumes the baked tables this crate produces and never links the profiles.
|
||||
#
|
||||
# No wgpu dependency either, deliberately. What comes out of here is plain
|
||||
# `f32` data with a documented layout; deciding it is a 3D texture is dr-gpu's
|
||||
# job, and keeping that decision out of here is what lets the whole spectral
|
||||
# model be tested on the CPU.
|
||||
[dependencies]
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_norway.workspace = true
|
||||
@@ -1,76 +0,0 @@
|
||||
# Film stocks
|
||||
|
||||
One file per stock in [`profiles/`](profiles/). Adding a stock is adding a
|
||||
file — no code change, no shader, no new operation — for the same reason
|
||||
`dr-decode`'s base curves work that way: under the GPLv3 a stock should be
|
||||
contributable without a release.
|
||||
|
||||
## What a profile is
|
||||
|
||||
Three measured tables, all of them published in the manufacturer's datasheet:
|
||||
|
||||
| Field | What it decides |
|
||||
|---|---|
|
||||
| `log_sensitivity` | what each emulsion layer *sees*, per wavelength |
|
||||
| `density_curves` | contrast, latitude, and where the stock clips |
|
||||
| `dye_density` | what the developed stock *looks* like, per wavelength |
|
||||
| `base_density` | the support: film base, and a colour negative's orange mask |
|
||||
|
||||
Plus `kind` (negative or positive), `support` (film or paper), and the two
|
||||
illuminants the data is referenced to. A print paper is a stock like any
|
||||
other; `support` exists so an interface can offer papers separately, not
|
||||
because the renderer treats them differently.
|
||||
|
||||
## Why it is not a LUT
|
||||
|
||||
Because the parameters stay physical. Opening up a stop moves the picture along
|
||||
the film's own characteristic curve — toe, shoulder and all — instead of
|
||||
scaling a number somebody baked at one exposure. A scanned negative comes out
|
||||
orange and inverted because that is what a negative *is*, and it becomes a
|
||||
photograph when a paper profile prints it, exactly as it would in a darkroom.
|
||||
|
||||
The data cost runs the other way from a LUT collection too: a stock is about
|
||||
17 kB of measurements, where one HaldCLUT is roughly 800 kB of one person's
|
||||
grade.
|
||||
|
||||
## How it runs
|
||||
|
||||
The spectral chain reduces to three tables, and the reduction is exact where it
|
||||
matters — see [`src/bake.rs`](src/bake.rs) for the argument:
|
||||
|
||||
1. **A 3×3 matrix**, linear sRGB to the three layers' exposure. Exact, not an
|
||||
approximation: the reconstructed scene spectrum is linear in the sRGB
|
||||
triple, so the integral collapses into nine numbers.
|
||||
2. **Three 1D curves**, log exposure to density, sampled at 256 points.
|
||||
3. **One 32³ lookup**, density to linear sRGB — dye absorption, the print
|
||||
through the negative, the paper, the viewing illuminant and the chromatic
|
||||
adaptation, all of which take exactly three numbers in.
|
||||
|
||||
Per pixel that is a matrix multiply, three curve taps and one texture fetch.
|
||||
Splitting 2 from 3, rather than baking one LUT over exposure, is measured
|
||||
rather than assumed: the curve carries all the sharp shape and the dye mixing
|
||||
is smooth, so folding the curve into the 3D lookup would need it three times
|
||||
larger for the same error. At 32³ the worst interpolation error is about 0.003
|
||||
in linear sRGB, below one 8-bit code value, and there is a test that says so.
|
||||
|
||||
## Adding a stock
|
||||
|
||||
If spektrafilm has it, add its name to `STOCKS` in
|
||||
[`tools/film-profiles/convert.py`](../../tools/film-profiles/convert.py) and
|
||||
re-run it. Otherwise write the YAML by hand from the datasheet; the loader
|
||||
validates the table lengths and says which file and field is wrong.
|
||||
|
||||
Either way, list it in `BUILT_IN` in [`src/lib.rs`](src/lib.rs) to compile it
|
||||
in — or drop it in the profile directory at runtime, which is the path meant
|
||||
for stocks that ship separately from the binary.
|
||||
|
||||
## Provenance
|
||||
|
||||
The shipped profiles are converted from
|
||||
[spektrafilm](https://github.com/andreavolpato/spektrafilm) by Andrea Volpato,
|
||||
licensed CC BY-SA 4.0. See [`profiles/LICENSE-PROFILES.txt`](profiles/LICENSE-PROFILES.txt)
|
||||
for the licence and [`profiles/CHANGELOG.txt`](profiles/CHANGELOG.txt) for what
|
||||
the conversion changed and what it deliberately did not.
|
||||
|
||||
The sRGB reflectance basis is Mallett & Yuksel (2019); the observer is the CIE
|
||||
1931 2°.
|
||||
@@ -1,74 +0,0 @@
|
||||
Changes made to the spektrafilm profiles shipped in this directory
|
||||
=================================================================
|
||||
|
||||
The profiles here are derived from spektrafilm by Andrea Volpato
|
||||
(https://github.com/andreavolpato/spektrafilm), licensed CC BY-SA 4.0. The
|
||||
full licence is in LICENSE-PROFILES.txt and is reproduced unchanged.
|
||||
|
||||
CC BY-SA 4.0 section 3(a)(1)(B) requires that a modified copy say it was
|
||||
modified. It was. This file says how, and tools/film-profiles/convert.py
|
||||
performs the modification, so it can be re-run against upstream and the result
|
||||
compared rather than taken on trust.
|
||||
|
||||
|
||||
What was changed
|
||||
----------------
|
||||
|
||||
1. Format. Upstream ships JSON; these are YAML, so that adding or correcting a
|
||||
stock is editing a legible file rather than a minified one. No value is
|
||||
altered by the reformat.
|
||||
|
||||
2. Trimmed to the fields this renderer reads:
|
||||
|
||||
kept info.*, data.wavelengths (implicitly, as the fixed grid),
|
||||
data.log_sensitivity, data.channel_density (renamed
|
||||
dye_density), data.base_density, data.log_exposure (kept as its
|
||||
two endpoints, since it is uniformly sampled),
|
||||
data.density_curves
|
||||
|
||||
dropped data.density_curves_model - a 3-CDF fit of the curves; the
|
||||
sampled curves are shipped
|
||||
instead, and reproduce it to
|
||||
0.004 density
|
||||
data.density_curves_layers - per-sublayer curves, used for
|
||||
grain, which is not implemented
|
||||
yet. Worth restoring when it is:
|
||||
real grain is per sublayer.
|
||||
data.hanatos2025_adaptation_* - parameters for a spectral
|
||||
upsampling method this renderer
|
||||
does not use; see below
|
||||
data.midscale_neutral_density - null in every profile shipped
|
||||
|
||||
Dropping fields loses nothing for the stocks shipped, but it does mean a
|
||||
re-run of the converter is needed to pick up an upstream field later.
|
||||
|
||||
3. Numbers are written at 6 significant figures (5 for the density curves).
|
||||
The inputs are digitised datasheet curves, so this is well inside their
|
||||
measurement error; it is what takes a profile from 207 kB to 17 kB.
|
||||
|
||||
4. Nulls made explicit. Upstream uses null where a datasheet has no reading.
|
||||
In log_sensitivity that means the layer is blind there, written here as the
|
||||
sentinel -9; in the density tables it means no absorption, written as 0.
|
||||
|
||||
|
||||
What was NOT changed
|
||||
--------------------
|
||||
|
||||
No measured value has been rescaled, shifted, smoothed or refitted. The
|
||||
renderer's own calibration conventions - mid-grey at 0.184, exposure
|
||||
normalised on the green layer - are taken from spektrafilm's reference
|
||||
implementation rather than invented, because the profile data is calibrated
|
||||
against them.
|
||||
|
||||
|
||||
Known deviation from upstream's rendering
|
||||
-----------------------------------------
|
||||
|
||||
Upstream reconstructs a spectrum from an RGB triple with Hanatos (2025), which
|
||||
needs a 4 MB coefficient table. This renderer uses the Mallett & Yuksel (2019)
|
||||
sRGB basis instead, which is three curves and about 1 kB, at some cost in how
|
||||
faithfully very saturated and out-of-gamut colours are handled. The
|
||||
hanatos2025_adaptation_* parameters in the upstream profiles are therefore
|
||||
unused here. This is a deliberate trade of accuracy at the gamut edge against
|
||||
shipping four megabytes, and it is the first thing to revisit if saturated
|
||||
colours look wrong.
|
||||
@@ -1,520 +0,0 @@
|
||||
================================================================================
|
||||
License for spektrafilm profiles, LUTs, and direct derivatives
|
||||
by Andrea Volpato
|
||||
License: CC BY-SA 4.0 | Preamble v1.1 (2026-05-28)
|
||||
================================================================================
|
||||
|
||||
|
||||
This license applies to the original spektrafilm profiles that can be found at
|
||||
https://github.com/andreavolpato/spektrafilm, in the subfolder
|
||||
src/spektrafilm/data/profiles, and to all direct derivatives of the profiles,
|
||||
such as copies in other projects, LUTs, or any other format that encodes the
|
||||
same content.
|
||||
|
||||
LUTs and similar artifacts are interpreted as direct encodings of the
|
||||
information in the original profiles. I chose not to release them under GPLv3
|
||||
because GPL's software-shaped "derivative work" model fits LUTs poorly and would
|
||||
have made adoption more difficult. CC BY-SA 4.0 preserves the same share-alike
|
||||
spirit in a form that matches how creative-grading assets are actually handled.
|
||||
|
||||
Please do not modify this file. Changes to profiles and LUTs should be tracked
|
||||
in a separate CHANGELOG.txt file shipped with them. A copy of this license is
|
||||
available at:
|
||||
https://github.com/andreavolpato/spektrafilm/blob/main/SPEKTRAFILM_LICENSE.txt
|
||||
|
||||
---
|
||||
|
||||
A note before the legal text.
|
||||
|
||||
spektrafilm is a film-emulation project, designed in the open, given away
|
||||
freely, and made to stay that way. Use it on anything. Share it widely. Build
|
||||
on it. The works you make are yours, completely.
|
||||
|
||||
What follows is the full text of the Creative Commons Attribution-ShareAlike
|
||||
4.0 International Public License (CC BY-SA 4.0). It is the legal floor. The
|
||||
spirit it exists to protect, in three lines:
|
||||
|
||||
- Free use forever, on any production, of any size.
|
||||
- Full credit to the source, on every copy and every derivative.
|
||||
- No parasitism.
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
WHAT YOU CAN DO
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Use these profiles and LUTs freely to grade anything - personal, educational,
|
||||
or commercial; small project or feature film; any platform, any deliverable.
|
||||
The works you make are yours. No royalties. No copyleft on your finished film
|
||||
or photograph. No obligation flowing to your distributor, broadcaster, or
|
||||
audience.
|
||||
|
||||
Share the files freely with friends, collaborators, vendors, and post houses.
|
||||
|
||||
Modify, remix, regrade, recombine. Modified profiles or LUTs must stay
|
||||
CC BY-SA 4.0 and credit spektrafilm.
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
ATTRIBUTION - ALWAYS, EVERYWHERE, FOREVER
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Every copy, redistribution, modification, or derivative of these files - in
|
||||
any format, in any context, across any chain of hands - must preserve:
|
||||
|
||||
- Andrea Volpato, named as the original author.
|
||||
- https://github.com/andreavolpato/spektrafilm, as the canonical source.
|
||||
- The CC BY-SA 4.0 license notice (this file, or a link to it).
|
||||
- A note that the file has been modified, if it has, and by whom.
|
||||
|
||||
This applies whether the LUT is shipped inside a .cube header, an ICC profile
|
||||
field, an app's About screen, a paid plugin, a derived .3dl, training-data
|
||||
documentation, or anywhere else. The form can vary; the information cannot
|
||||
disappear.
|
||||
|
||||
Removing or hiding this information ends the license grant immediately
|
||||
(CC BY-SA 4.0, Section 6(a)).
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
SUGGESTED ATTRIBUTION TEXT
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
For unmodified files:
|
||||
|
||||
spektrafilm by Andrea Volpato
|
||||
https://github.com/andreavolpato/spektrafilm
|
||||
Licensed CC BY-SA 4.0
|
||||
|
||||
For modified files:
|
||||
|
||||
Derived from spektrafilm by Andrea Volpato
|
||||
https://github.com/andreavolpato/spektrafilm
|
||||
Licensed CC BY-SA 4.0
|
||||
Modified by [your name].
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
WHAT I ASK, BEYOND THE LEGAL FLOOR
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
These are not legal requirements. They are the spirit of the project, stated
|
||||
plainly, so nobody has to guess.
|
||||
|
||||
- Keep this file unchanged when sharing profiles/LUTs, and if changes were
|
||||
made describe them in a file CHANGELOG.txt shipped together with this
|
||||
license.
|
||||
|
||||
- Credit spektrafilm in the human layer too - project docs, about pages,
|
||||
credits, breakdowns. Not required on a finished film or photograph, but
|
||||
appreciated wherever it's natural.
|
||||
|
||||
- Don't use "spektrafilm" or my name in product branding without asking.
|
||||
The license covers the files; the name is not part of that grant.
|
||||
Factual reference is welcome and even encouraged, for example "graded with
|
||||
spektrafilm", or "this app uses spektrafilm LUTs".
|
||||
|
||||
- Paid apps and tools that add real value are welcome. Charge for what you
|
||||
built - UI, performance, support, a service. Please don't repackage the
|
||||
LUTs themselves as a paid LUT pack: CC BY-SA technically allows it, but
|
||||
that's not the spirit of the project, and any buyer can legally
|
||||
redistribute for free anyway.
|
||||
|
||||
- Please don't train commercial AI models on the LUTs or their outputs.
|
||||
CC BY-SA doesn't forbid this, but it is asked. Models that approximate
|
||||
or replace the spektrafilm look defeat the purpose of giving these away
|
||||
freely. Non-commercial research training is fine.
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
CONTACT
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
GitHub Issues or Discussions on the spektrafilm repository.
|
||||
|
||||
For private inquiries about commercial licensing, name use, or custom work,
|
||||
write to andrea.volpato@outlook.com.
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
A small thank you
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
If spektrafilm finds its way into your work, that is already the reward. If
|
||||
you want to credit it generously, share what you made, or just say hi, please do
|
||||
not hesitate. Make beautiful images.
|
||||
|
||||
Andrea Volpato
|
||||
|
||||
|
||||
================================================================================
|
||||
END OF SPEKTRAFILM PREAMBLE - BEGIN CC BY-SA 4.0 LICENSE TEXT (verbatim)
|
||||
================================================================================
|
||||
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution-ShareAlike 4.0 International Public License ("Public
|
||||
License"). To the extent this Public License may be interpreted as a
|
||||
contract, You are granted the Licensed Rights in consideration of Your
|
||||
acceptance of these terms and conditions, and the Licensor grants You
|
||||
such rights in consideration of benefits the Licensor receives from
|
||||
making the Licensed Material available under these terms and
|
||||
conditions.
|
||||
|
||||
|
||||
Section 1 -- Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. BY-SA Compatible License means a license listed at
|
||||
creativecommons.org/compatiblelicenses, approved by Creative
|
||||
Commons as essentially the equivalent of this Public License.
|
||||
|
||||
d. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
performance, broadcast, sound recording, and Sui Generis Database
|
||||
Rights, without regard to how the rights are labeled or
|
||||
categorized. For purposes of this Public License, the rights
|
||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||
Rights.
|
||||
|
||||
e. Effective Technological Measures means those measures that, in the
|
||||
absence of proper authority, may not be circumvented under laws
|
||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||
Treaty adopted on December 20, 1996, and/or similar international
|
||||
agreements.
|
||||
|
||||
f. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||
any other exception or limitation to Copyright and Similar Rights
|
||||
that applies to Your use of the Licensed Material.
|
||||
|
||||
g. License Elements means the license attributes listed in the name
|
||||
of a Creative Commons Public License. The License Elements of this
|
||||
Public License are Attribution and ShareAlike.
|
||||
|
||||
h. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
i. Licensed Rights means the rights granted to You subject to the
|
||||
terms and conditions of this Public License, which are limited to
|
||||
all Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
j. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
k. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
l. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and of
|
||||
the Council of 11 March 1996 on the legal protection of databases,
|
||||
as amended and/or succeeded, as well as other essentially
|
||||
equivalent rights anywhere in the world.
|
||||
|
||||
m. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
|
||||
Section 2 -- Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
a. reproduce and Share the Licensed Material, in whole or
|
||||
in part; and
|
||||
|
||||
b. produce, reproduce, and Share Adapted Material.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||
Exceptions and Limitations apply to Your use, this Public
|
||||
License does not apply, and You do not need to comply with
|
||||
its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section
|
||||
6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
a. Offer from the Licensor -- Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
b. Additional offer from the Licensor -- Adapted Material.
|
||||
Every recipient of Adapted Material from You
|
||||
automatically receives an offer from the Licensor to
|
||||
exercise the Licensed Rights in the Adapted Material
|
||||
under the conditions of the Adapter's License You apply.
|
||||
|
||||
c. No downstream restrictions. You may not offer or impose
|
||||
any additional or different terms or conditions on, or
|
||||
apply any Effective Technological Measures to, the
|
||||
Licensed Material if doing so restricts exercise of the
|
||||
Licensed Rights by any recipient of the Licensed
|
||||
Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not
|
||||
licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to
|
||||
the extent possible, the Licensor waives and/or agrees not to
|
||||
assert any such rights held by the Licensor to the limited
|
||||
extent necessary to allow You to exercise the Licensed
|
||||
Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this
|
||||
Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to
|
||||
collect royalties from You for the exercise of the Licensed
|
||||
Rights, whether directly or through a collecting society
|
||||
under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly
|
||||
reserves any right to collect such royalties.
|
||||
|
||||
|
||||
Section 3 -- License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the
|
||||
following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
a. retain the following if it is supplied by the Licensor
|
||||
with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed
|
||||
Material and any others designated to receive
|
||||
attribution, in any reasonable manner requested by
|
||||
the Licensor (including by pseudonym if
|
||||
designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the
|
||||
extent reasonably practicable;
|
||||
|
||||
b. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
c. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI or
|
||||
hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may be
|
||||
reasonable to satisfy the conditions by providing a URI or
|
||||
hyperlink to a resource that includes the required
|
||||
information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
b. ShareAlike.
|
||||
|
||||
In addition to the conditions in Section 3(a), if You Share
|
||||
Adapted Material You produce, the following conditions also apply.
|
||||
|
||||
1. The Adapter's License You apply must be a Creative Commons
|
||||
license with the same License Elements, this version or
|
||||
later, or a BY-SA Compatible License.
|
||||
|
||||
2. You must include the text of, or the URI or hyperlink to, the
|
||||
Adapter's License You apply. You may satisfy this condition
|
||||
in any reasonable manner based on the medium, means, and
|
||||
context in which You Share Adapted Material.
|
||||
|
||||
3. You may not offer or impose any additional or different terms
|
||||
or conditions on, or apply any Effective Technological
|
||||
Measures to, Adapted Material that restrict exercise of the
|
||||
rights granted under the Adapter's License You apply.
|
||||
|
||||
|
||||
Section 4 -- Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material,
|
||||
including for purposes of Section 3(b); and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights include other Copyright and Similar Rights.
|
||||
|
||||
|
||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||
|
||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
|
||||
Section 6 -- Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply with
|
||||
this Public License, then Your rights under this Public License
|
||||
terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under
|
||||
Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided
|
||||
it is cured within 30 days of Your discovery of the
|
||||
violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
|
||||
Section 7 -- Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
|
||||
Section 8 -- Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is
|
||||
deemed unenforceable, it shall be automatically reformed to the
|
||||
minimum extent necessary to make it enforceable. If the provision
|
||||
cannot be reformed, it shall be severed from this Public License
|
||||
without affecting the enforceability of the remaining terms and
|
||||
conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no
|
||||
failure to comply consented to unless expressly agreed to by the
|
||||
Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons is not a party to its public licenses. Notwithstanding,
|
||||
Creative Commons may elect to apply one of its public licenses to material it
|
||||
publishes and in those instances will be considered the "Licensor." The text
|
||||
of the Creative Commons public licenses is dedicated to the public domain
|
||||
under the CC0 Public Domain Dedication. Except for the limited purpose of
|
||||
indicating that material is shared under a Creative Commons public license or
|
||||
as otherwise permitted by the Creative Commons policies published at
|
||||
creativecommons.org/policies, Creative Commons does not authorize the use of
|
||||
the trademark "Creative Commons" or any other trademark or logo of Creative
|
||||
Commons without its prior written consent including, without limitation, in
|
||||
connection with any unauthorized modifications to any of its public licenses
|
||||
or any other arrangements, understandings, or agreements concerning use of
|
||||
licensed material. For the avoidance of doubt, this paragraph does not form
|
||||
part of the public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
||||
@@ -1,453 +0,0 @@
|
||||
# Generated by tools/film-profiles/convert.py from spektrafilm.
|
||||
# Do not edit by hand: re-run the converter instead.
|
||||
#
|
||||
# spektrafilm by Andrea Volpato, https://github.com/andreavolpato/spektrafilm
|
||||
# Licensed CC BY-SA 4.0. Modified for DarkRoom: trimmed to the fields the
|
||||
# renderer uses and reformatted; see profiles/CHANGELOG.txt.
|
||||
|
||||
version: '0.3.2'
|
||||
stock: fujifilm_c200
|
||||
name: 'Fujifilm C200'
|
||||
kind: negative # negative | positive
|
||||
support: film # film | paper
|
||||
stage: filming # filming | printing
|
||||
monochrome: false
|
||||
reference_illuminant: D55
|
||||
viewing_illuminant: D50
|
||||
target_print: fujifilm_crystal_archive_typeii
|
||||
|
||||
# log10 spectral sensitivity per layer, 380-780nm at 5nm, in R,G,B layer
|
||||
# order. A null upstream means the datasheet has no reading there, which is
|
||||
# blindness, so it is written as the sentinel the loader reads as such.
|
||||
log_sensitivity:
|
||||
- [-4.49474, -3.7984, -0.780095] # 380nm
|
||||
- [-4.46591, -3.72613, -0.760927] # 385nm
|
||||
- [-4.43605, -3.65042, -0.740093] # 390nm
|
||||
- [-4.40511, -3.57101, -0.717364] # 395nm
|
||||
- [-4.37302, -3.48763, -0.692471] # 400nm
|
||||
- [-4.33971, -3.39998, -0.665089] # 405nm
|
||||
- [-4.30513, -3.30772, -0.636688] # 410nm
|
||||
- [-4.26919, -3.21047, -0.612675] # 415nm
|
||||
- [-4.23182, -3.10781, -0.596383] # 420nm
|
||||
- [-4.19291, -2.99929, -0.585538] # 425nm
|
||||
- [-4.15239, -2.88438, -0.579499] # 430nm
|
||||
- [-4.11014, -2.76251, -0.574724] # 435nm
|
||||
- [-4.06606, -2.63303, -0.57049] # 440nm
|
||||
- [-4.02002, -2.49519, -0.566659] # 445nm
|
||||
- [-3.97188, -2.34816, -0.557936] # 450nm
|
||||
- [-3.92151, -2.19099, -0.534054] # 455nm
|
||||
- [-3.86873, -2.0226, -0.494042] # 460nm
|
||||
- [-3.81338, -1.84173, -0.427312] # 465nm
|
||||
- [-3.75527, -1.65616, -0.379735] # 470nm
|
||||
- [-3.69417, -1.50853, -0.388078] # 475nm
|
||||
- [-3.62986, -1.3774, -0.454728] # 480nm
|
||||
- [-3.56207, -1.25803, -0.607564] # 485nm
|
||||
- [-3.49052, -1.14821, -0.848656] # 490nm
|
||||
- [-3.41488, -1.04849, -1.26772] # 495nm
|
||||
- [-3.33478, -0.975971, -1.66208] # 500nm
|
||||
- [-3.24984, -0.915464, -1.89074] # 505nm
|
||||
- [-3.15958, -0.861407, -2.21173] # 510nm
|
||||
- [-3.0635, -0.815729, -2.50355] # 515nm
|
||||
- [-2.96102, -0.766704, -2.76998] # 520nm
|
||||
- [-2.85147, -0.694583, -3.01422] # 525nm
|
||||
- [-2.73409, -0.610862, -3.23892] # 530nm
|
||||
- [-2.60802, -0.529292, -3.44633] # 535nm
|
||||
- [-2.47225, -0.455318, -3.63837] # 540nm
|
||||
- [-2.32562, -0.394037, -3.8167] # 545nm
|
||||
- [-2.16677, -0.343176, -3.98274] # 550nm
|
||||
- [-1.99411, -0.310025, -4.1377] # 555nm
|
||||
- [-1.80575, -0.302322, -4.28266] # 560nm
|
||||
- [-1.59945, -0.319278, -4.41857] # 565nm
|
||||
- [-1.37253, -0.385972, -4.54624] # 570nm
|
||||
- [-1.13632, -0.587991, -4.66639] # 575nm
|
||||
- [-0.945888, -0.921803, -4.77969] # 580nm
|
||||
- [-0.779331, -1.34203, -4.88668] # 585nm
|
||||
- [-0.665018, -1.78008, -4.9879] # 590nm
|
||||
- [-0.566605, -1.98531, -5.08379] # 595nm
|
||||
- [-0.492681, -2.04868, -5.17476] # 600nm
|
||||
- [-0.440289, -2.27014, -5.26118] # 605nm
|
||||
- [-0.411973, -2.47632, -5.34338] # 610nm
|
||||
- [-0.398393, -2.66876, -5.42167] # 615nm
|
||||
- [-0.387004, -2.84879, -5.49632] # 620nm
|
||||
- [-0.376404, -3.01756, -5.56758] # 625nm
|
||||
- [-0.366055, -3.17611, -5.63567] # 630nm
|
||||
- [-0.354218, -3.32533, -5.7008] # 635nm
|
||||
- [-0.343407, -3.46602, -5.76316] # 640nm
|
||||
- [-0.354363, -3.59889, -5.82292] # 645nm
|
||||
- [-0.428961, -3.72459, -5.88024] # 650nm
|
||||
- [-0.656074, -3.84366, -5.93526] # 655nm
|
||||
- [-0.946249, -3.95663, -5.98813] # 660nm
|
||||
- [-1.2394, -4.06396, -6.03897] # 665nm
|
||||
- [-1.50679, -4.16604, -6.08789] # 670nm
|
||||
- [-1.77081, -4.26327, -6.13499] # 675nm
|
||||
- [-2.01083, -4.35597, -6.18039] # 680nm
|
||||
- [-2.22998, -4.44446, -6.22416] # 685nm
|
||||
- [-2.43087, -4.52902, -6.2664] # 690nm
|
||||
- [-2.61568, -4.6099, -6.30718] # 695nm
|
||||
- [-2.78628, -4.68734, -6.34657] # 700nm
|
||||
- [-2.94424, -4.76155, -6.38466] # 705nm
|
||||
- [-3.09092, -4.83274, -6.42149] # 710nm
|
||||
- [-3.22748, -4.90107, -6.45714] # 715nm
|
||||
- [-3.35494, -4.96673, -6.49165] # 720nm
|
||||
- [-3.47418, -5.02986, -6.52509] # 725nm
|
||||
- [-3.58596, -5.09061, -6.5575] # 730nm
|
||||
- [-3.69097, -5.14911, -6.58893] # 735nm
|
||||
- [-3.7898, -5.20548, -6.61941] # 740nm
|
||||
- [-3.88299, -5.25984, -6.649] # 745nm
|
||||
- [-3.97099, -5.31229, -6.67774] # 750nm
|
||||
- [-4.05425, -5.36293, -6.70565] # 755nm
|
||||
- [-4.13311, -5.41186, -6.73278] # 760nm
|
||||
- [-4.20794, -5.45915, -6.75915] # 765nm
|
||||
- [-4.27902, -5.50489, -6.7848] # 770nm
|
||||
- [-4.34664, -5.54916, -6.80976] # 775nm
|
||||
- [-4.41103, -5.59203, -6.83405] # 780nm
|
||||
|
||||
# Spectral density of each layer's dye at unit density, same grid and order.
|
||||
dye_density:
|
||||
- [0, 0, 0] # 380nm
|
||||
- [0.219934, 0.0690457, 0.197424] # 385nm
|
||||
- [0.180055, 0.0605677, 0.28454] # 390nm
|
||||
- [0.141725, 0.0508041, 0.368818] # 395nm
|
||||
- [0.10596, 0.0400928, 0.454706] # 400nm
|
||||
- [0.0734071, 0.0290889, 0.545617] # 405nm
|
||||
- [0.044698, 0.0185143, 0.641508] # 410nm
|
||||
- [0.0205128, 0.00884686, 0.739868] # 415nm
|
||||
- [0.00143889, 0.000152162, 0.83745] # 420nm
|
||||
- [-0.0121294, -0.0078213, 0.930727] # 425nm
|
||||
- [-0.0201355, -0.0153753, 1.01557] # 430nm
|
||||
- [-0.0229945, -0.0225837, 1.08729] # 435nm
|
||||
- [-0.0215373, -0.0290263, 1.14151] # 440nm
|
||||
- [-0.0168424, -0.0335359, 1.17517] # 445nm
|
||||
- [-0.0100824, -0.0341408, 1.18658] # 450nm
|
||||
- [-0.00236244, -0.0285442, 1.17471] # 455nm
|
||||
- [0.00540939, -0.0150457, 1.13895] # 460nm
|
||||
- [0.0125527, 0.00699094, 1.07976] # 465nm
|
||||
- [0.0185758, 0.0380109, 0.999158] # 470nm
|
||||
- [0.0231082, 0.079671, 0.900957] # 475nm
|
||||
- [0.0259004, 0.134705, 0.790535] # 480nm
|
||||
- [0.0268431, 0.205041, 0.674204] # 485nm
|
||||
- [0.0259478, 0.290603, 0.558042] # 490nm
|
||||
- [0.0232873, 0.389795, 0.446837] # 495nm
|
||||
- [0.0189498, 0.500236, 0.344137] # 500nm
|
||||
- [0.0130829, 0.618489, 0.252809] # 505nm
|
||||
- [0.00599797, 0.739757, 0.175046] # 510nm
|
||||
- [-0.00178396, 0.858726, 0.111939] # 515nm
|
||||
- [-0.00959523, 0.970773, 0.0632207] # 520nm
|
||||
- [-0.0167028, 1.07222, 0.0275014] # 525nm
|
||||
- [-0.0223299, 1.15986, 0.00276196] # 530nm
|
||||
- [-0.025608, 1.23008, -0.0131247] # 535nm
|
||||
- [-0.0255269, 1.27834, -0.0219903] # 540nm
|
||||
- [-0.0209923, 1.30026, -0.0253764] # 545nm
|
||||
- [-0.0109847, 1.29377, -0.0247663] # 550nm
|
||||
- [0.00535715, 1.25966, -0.0216485] # 555nm
|
||||
- [0.0287857, 1.20031, -0.0172561] # 560nm
|
||||
- [0.0599359, 1.11845, -0.0124018] # 565nm
|
||||
- [0.0991434, 1.01749, -0.00757597] # 570nm
|
||||
- [0.146168, 0.902569, -0.00309889] # 575nm
|
||||
- [0.200014, 0.780993, 0.000835038] # 580nm
|
||||
- [0.259016, 0.660985, 0.00418266] # 585nm
|
||||
- [0.321326, 0.549366, 0.0070436] # 590nm
|
||||
- [0.38551, 0.450047, 0.00954195] # 595nm
|
||||
- [0.450713, 0.364545, 0.011655] # 600nm
|
||||
- [0.516397, 0.293068, 0.0131587] # 605nm
|
||||
- [0.581981, 0.234893, 0.0137522] # 610nm
|
||||
- [0.646723, 0.188327, 0.0132301] # 615nm
|
||||
- [0.709967, 0.150922, 0.0116262] # 620nm
|
||||
- [0.771511, 0.120142, 0.00928497] # 625nm
|
||||
- [0.831605, 0.0942039, 0.00679313] # 630nm
|
||||
- [0.890487, 0.072248, 0.004726] # 635nm
|
||||
- [0.947953, 0.0538859, 0.003342] # 640nm
|
||||
- [1.00349, 0.03879, 0.00253006] # 645nm
|
||||
- [1.05676, 0.0266405, 0.00203029] # 650nm
|
||||
- [1.10771, 0.0172062, 0.00164137] # 655nm
|
||||
- [1.15629, 0.0103978, 0.00127914] # 660nm
|
||||
- [1.20202, 0.00621151, 0.000938116] # 665nm
|
||||
- [1.24377, 0.00458551, 0.00063837] # 670nm
|
||||
- [1.27996, 0.00527832, 0.000395263] # 675nm
|
||||
- [1.30926, 0.00784651, 0.000211127] # 680nm
|
||||
- [1.33124, 0.0117029, 7.69888e-05] # 685nm
|
||||
- [1.34632, 0.0162157, -1.40847e-06] # 690nm
|
||||
- [1.35498, 0.0208201, -7.37939e-07] # 695nm
|
||||
- [1.35683, 0.0250818, -3.79985e-07] # 700nm
|
||||
- [1.35067, 0.0286858, -1.92302e-07] # 705nm
|
||||
- [1.3351, 0.0314337, -9.56482e-08] # 710nm
|
||||
- [1.30924, 0.0332902, -4.67565e-08] # 715nm
|
||||
- [1.27315, 0.0343537, -2.24636e-08] # 720nm
|
||||
- [1.22786, 0.0347916, -1.0607e-08] # 725nm
|
||||
- [1.17506, 0.0347891, -4.9224e-09] # 730nm
|
||||
- [1.11643, 0.0345198, -2.24509e-09] # 735nm
|
||||
- [1.0534, 0.0341338, 8.61996e-05] # 740nm
|
||||
- [0.987186, 0.0337561, 0.000207593] # 745nm
|
||||
- [0, 0.0334908, 0.000267898] # 750nm
|
||||
- [0, 0, 0] # 755nm
|
||||
- [0, 0, 0] # 760nm
|
||||
- [0, 0, 0] # 765nm
|
||||
- [0, 0, 0] # 770nm
|
||||
- [0, 0, 0] # 775nm
|
||||
- [0, 0, 0] # 780nm
|
||||
|
||||
# The support's own density -- film base plus, for a colour negative, the
|
||||
# orange mask. Flat zero where the datasheet does not give it.
|
||||
base_density: [0, 0, 0, 0, 0.933414, 0.897549, 0.876086, 0.866033, 0.870645, 0.882094, 0.892707, 0.900328, 0.902069, 0.895052, 0.884664, 0.872443, 0.857621, 0.842124, 0.825051, 0.807508, 0.788897, 0.770294, 0.751929, 0.735483, 0.71846, 0.702528, 0.691316, 0.681951, 0.673479, 0.664148, 0.654181, 0.643917, 0.632416, 0.620377, 0.60762, 0.59383, 0.580154, 0.561819, 0.527487, 0.490929, 0.446639, 0.40331, 0.3619, 0.324986, 0.284903, 0.262975, 0.249335, 0.242079, 0.239187, 0.23937, 0.239839, 0.244782, 0.250344, 0.257017, 0.264548, 0.271892, 0.279015, 0.281704, 0.281046, 0.275089, 0.268824, 0.261894, 0.254735, 0.246873, 0.239905, 0.23131, 0.222705, 0.214196, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
|
||||
# The characteristic curves: density against log10 exposure, sampled
|
||||
# uniformly over [-3, 4].
|
||||
log_exposure_min: -3
|
||||
log_exposure_max: 4
|
||||
density_curves:
|
||||
- [-0.00013078, -0.0007119, -0.00082588]
|
||||
- [7.0327e-05, -0.00058386, -0.00074058]
|
||||
- [0.00035341, -0.00036479, -0.00059121]
|
||||
- [0.00066733, -0.00013673, -0.00039991]
|
||||
- [0.00096611, 6.3991e-06, -0.00016937]
|
||||
- [0.0012393, 3.0309e-05, 8.8551e-05]
|
||||
- [0.0015194, 4.2329e-06, 0.00031585]
|
||||
- [0.0018493, 4.5759e-05, 0.00042728]
|
||||
- [0.0022351, 0.00020307, 0.00038164]
|
||||
- [0.0026342, 0.00039831, 0.00022762]
|
||||
- [0.0029943, 0.00050018, 6.6053e-05]
|
||||
- [0.0033041, 0.00044924, -3.3788e-05]
|
||||
- [0.0035839, 0.00028717, -5.1736e-05]
|
||||
- [0.0038805, 0.00011785, -3.7112e-06]
|
||||
- [0.0042462, 3.9574e-05, 8.4039e-05]
|
||||
- [0.0047191, 9.0539e-05, 0.00019234]
|
||||
- [0.0053195, 0.00024148, 0.0003051]
|
||||
- [0.0060587, 0.00042993, 0.00039527]
|
||||
- [0.0069464, 0.00060219, 0.00042321]
|
||||
- [0.0079838, 0.00073226, 0.00035826]
|
||||
- [0.0091474, 0.0008141, 0.00020985]
|
||||
- [0.010382, 0.00084798, 3.9792e-05]
|
||||
- [0.011617, 0.00084172, -6.2735e-05]
|
||||
- [0.012809, 0.0008257, -3.3269e-05]
|
||||
- [0.013983, 0.00085935, 0.00012393]
|
||||
- [0.015241, 0.0010094, 0.00033413]
|
||||
- [0.016724, 0.0013062, 0.00049864]
|
||||
- [0.018543, 0.0017108, 0.00055964]
|
||||
- [0.020721, 0.002128, 0.00053845]
|
||||
- [0.02318, 0.0024666, 0.00051961]
|
||||
- [0.025793, 0.0027096, 0.00059163]
|
||||
- [0.028462, 0.0029405, 0.00078607]
|
||||
- [0.031184, 0.0032967, 0.001057]
|
||||
- [0.034051, 0.0038757, 0.0013133]
|
||||
- [0.0372, 0.0046594, 0.0014795]
|
||||
- [0.040732, 0.0055137, 0.0015425]
|
||||
- [0.044657, 0.0062738, 0.001556]
|
||||
- [0.048901, 0.0068592, 0.0016051]
|
||||
- [0.053358, 0.0073409, 0.0017608]
|
||||
- [0.057954, 0.0079116, 0.0020535]
|
||||
- [0.062688, 0.0087773, 0.0024756]
|
||||
- [0.067628, 0.010041, 0.0030004]
|
||||
- [0.072875, 0.011652, 0.0035958]
|
||||
- [0.07853, 0.013452, 0.0042261]
|
||||
- [0.084658, 0.015279, 0.0048502]
|
||||
- [0.091278, 0.017052, 0.0054333]
|
||||
- [0.098343, 0.018802, 0.0059743]
|
||||
- [0.10574, 0.020632, 0.0065296]
|
||||
- [0.11331, 0.022658, 0.0072096]
|
||||
- [0.12088, 0.024972, 0.0081306]
|
||||
- [0.12839, 0.027619, 0.0093308]
|
||||
- [0.13597, 0.030616, 0.010789]
|
||||
- [0.14395, 0.034015, 0.01249]
|
||||
- [0.15264, 0.037831, 0.014403]
|
||||
- [0.16205, 0.041932, 0.016418]
|
||||
- [0.1719, 0.046052, 0.018388]
|
||||
- [0.18185, 0.049986, 0.020293]
|
||||
- [0.19176, 0.053842, 0.022335]
|
||||
- [0.20173, 0.058068, 0.024806]
|
||||
- [0.21191, 0.063149, 0.027808]
|
||||
- [0.22246, 0.069308, 0.031188]
|
||||
- [0.23354, 0.076377, 0.034689]
|
||||
- [0.24522, 0.08389, 0.038152]
|
||||
- [0.25743, 0.091387, 0.041624]
|
||||
- [0.26991, 0.098704, 0.045314]
|
||||
- [0.28228, 0.10604, 0.049442]
|
||||
- [0.2942, 0.11376, 0.054103]
|
||||
- [0.30556, 0.12205, 0.059269]
|
||||
- [0.31661, 0.13077, 0.0649]
|
||||
- [0.32792, 0.13955, 0.071049]
|
||||
- [0.34004, 0.14821, 0.077846]
|
||||
- [0.3532, 0.15696, 0.085364]
|
||||
- [0.36714, 0.16637, 0.093464]
|
||||
- [0.3812, 0.17702, 0.1018]
|
||||
- [0.39469, 0.18899, 0.11003]
|
||||
- [0.40734, 0.20181, 0.11803]
|
||||
- [0.41942, 0.21467, 0.12611]
|
||||
- [0.43155, 0.22704, 0.13485]
|
||||
- [0.44429, 0.23898, 0.14478]
|
||||
- [0.4578, 0.25103, 0.15607]
|
||||
- [0.47178, 0.26379, 0.16847]
|
||||
- [0.48572, 0.27738, 0.18147]
|
||||
- [0.4993, 0.29133, 0.19463]
|
||||
- [0.51254, 0.30504, 0.20779]
|
||||
- [0.52574, 0.31829, 0.22099]
|
||||
- [0.53916, 0.33155, 0.23439]
|
||||
- [0.55271, 0.34565, 0.24805]
|
||||
- [0.56604, 0.36118, 0.26203]
|
||||
- [0.57884, 0.37791, 0.27644]
|
||||
- [0.59114, 0.39474, 0.29142]
|
||||
- [0.60338, 0.41048, 0.30722]
|
||||
- [0.61609, 0.42475, 0.32412]
|
||||
- [0.62961, 0.43819, 0.34225]
|
||||
- [0.64388, 0.45199, 0.3613]
|
||||
- [0.65847, 0.46705, 0.3805]
|
||||
- [0.67292, 0.4834, 0.39899]
|
||||
- [0.68691, 0.50033, 0.41653]
|
||||
- [0.70046, 0.51693, 0.43361]
|
||||
- [0.71384, 0.53269, 0.45097]
|
||||
- [0.7273, 0.54778, 0.46914]
|
||||
- [0.74083, 0.56275, 0.48812]
|
||||
- [0.75426, 0.57811, 0.50761]
|
||||
- [0.76746, 0.59383, 0.52731]
|
||||
- [0.78062, 0.60947, 0.54723]
|
||||
- [0.79426, 0.62454, 0.56752]
|
||||
- [0.80894, 0.63892, 0.58822]
|
||||
- [0.82491, 0.65302, 0.60902]
|
||||
- [0.84179, 0.66743, 0.6293]
|
||||
- [0.85875, 0.68256, 0.64862]
|
||||
- [0.8749, 0.69834, 0.66708]
|
||||
- [0.88983, 0.71432, 0.68535]
|
||||
- [0.90378, 0.73002, 0.7043]
|
||||
- [0.91748, 0.7452, 0.72439]
|
||||
- [0.93167, 0.75987, 0.7454]
|
||||
- [0.94672, 0.77416, 0.76658]
|
||||
- [0.9626, 0.78828, 0.78698]
|
||||
- [0.97908, 0.80246, 0.80594]
|
||||
- [0.99578, 0.81669, 0.82363]
|
||||
- [1.0122, 0.8306, 0.84121]
|
||||
- [1.028, 0.84395, 0.85998]
|
||||
- [1.0435, 0.85725, 0.8803]
|
||||
- [1.0593, 0.87158, 0.9012]
|
||||
- [1.076, 0.88755, 0.92135]
|
||||
- [1.093, 0.90439, 0.94018]
|
||||
- [1.1096, 0.92068, 0.95823]
|
||||
- [1.1251, 0.93532, 0.97646]
|
||||
- [1.1399, 0.94838, 0.99539]
|
||||
- [1.1546, 0.96103, 1.0147]
|
||||
- [1.1701, 0.97479, 1.0335]
|
||||
- [1.1867, 0.99043, 1.051]
|
||||
- [1.2039, 1.0075, 1.0675]
|
||||
- [1.2208, 1.0249, 1.0837]
|
||||
- [1.2369, 1.0413, 1.1007]
|
||||
- [1.2521, 1.0566, 1.1189]
|
||||
- [1.2667, 1.0713, 1.1378]
|
||||
- [1.2812, 1.0867, 1.1566]
|
||||
- [1.2958, 1.1031, 1.1747]
|
||||
- [1.3102, 1.1205, 1.1926]
|
||||
- [1.3243, 1.1382, 1.2111]
|
||||
- [1.3378, 1.1556, 1.2309]
|
||||
- [1.351, 1.1724, 1.2515]
|
||||
- [1.364, 1.1885, 1.2715]
|
||||
- [1.377, 1.2041, 1.2899]
|
||||
- [1.3903, 1.2196, 1.3064]
|
||||
- [1.4036, 1.2352, 1.3219]
|
||||
- [1.4164, 1.251, 1.338]
|
||||
- [1.4286, 1.2669, 1.3557]
|
||||
- [1.4404, 1.283, 1.3753]
|
||||
- [1.452, 1.2995, 1.3959]
|
||||
- [1.4636, 1.3164, 1.4164]
|
||||
- [1.4751, 1.3338, 1.4361]
|
||||
- [1.4862, 1.3513, 1.4549]
|
||||
- [1.4968, 1.3685, 1.4735]
|
||||
- [1.5069, 1.3854, 1.4925]
|
||||
- [1.5167, 1.402, 1.512]
|
||||
- [1.5262, 1.4187, 1.5318]
|
||||
- [1.5353, 1.4356, 1.5511]
|
||||
- [1.5437, 1.4524, 1.5696]
|
||||
- [1.5517, 1.4688, 1.5878]
|
||||
- [1.5593, 1.4849, 1.6062]
|
||||
- [1.5672, 1.5007, 1.6256]
|
||||
- [1.5754, 1.5166, 1.6461]
|
||||
- [1.5837, 1.5329, 1.6675]
|
||||
- [1.5916, 1.5498, 1.689]
|
||||
- [1.5985, 1.5668, 1.71]
|
||||
- [1.6042, 1.5833, 1.7303]
|
||||
- [1.6092, 1.5986, 1.7502]
|
||||
- [1.6143, 1.6127, 1.7702]
|
||||
- [1.62, 1.6261, 1.7904]
|
||||
- [1.626, 1.6393, 1.8106]
|
||||
- [1.6316, 1.653, 1.8301]
|
||||
- [1.6363, 1.6669, 1.8487]
|
||||
- [1.6402, 1.6811, 1.8669]
|
||||
- [1.6439, 1.6951, 1.8849]
|
||||
- [1.6478, 1.7088, 1.9036]
|
||||
- [1.6521, 1.722, 1.923]
|
||||
- [1.6563, 1.7345, 1.943]
|
||||
- [1.6598, 1.7462, 1.963]
|
||||
- [1.6624, 1.7575, 1.9823]
|
||||
- [1.6645, 1.7686, 2.0009]
|
||||
- [1.6667, 1.78, 2.0188]
|
||||
- [1.6694, 1.7917, 2.0365]
|
||||
- [1.6726, 1.8036, 2.0545]
|
||||
- [1.6757, 1.815, 2.073]
|
||||
- [1.6781, 1.8255, 2.0917]
|
||||
- [1.6795, 1.8351, 2.1099]
|
||||
- [1.6803, 1.8442, 2.1272]
|
||||
- [1.6813, 1.8532, 2.1436]
|
||||
- [1.683, 1.8624, 2.1594]
|
||||
- [1.6854, 1.8718, 2.1752]
|
||||
- [1.6879, 1.8807, 2.1916]
|
||||
- [1.6899, 1.8889, 2.2084]
|
||||
- [1.691, 1.8963, 2.225]
|
||||
- [1.6915, 1.9031, 2.241]
|
||||
- [1.6919, 1.9101, 2.256]
|
||||
- [1.6927, 1.9175, 2.2701]
|
||||
- [1.694, 1.9252, 2.2838]
|
||||
- [1.6955, 1.9325, 2.2977]
|
||||
- [1.6967, 1.9388, 2.3119]
|
||||
- [1.6973, 1.9439, 2.3262]
|
||||
- [1.6975, 1.9482, 2.3401]
|
||||
- [1.6975, 1.9523, 2.3536]
|
||||
- [1.6976, 1.9569, 2.3665]
|
||||
- [1.6979, 1.9621, 2.379]
|
||||
- [1.6984, 1.9675, 2.391]
|
||||
- [1.6991, 1.9727, 2.4023]
|
||||
- [1.6998, 1.9776, 2.413]
|
||||
- [1.7005, 1.9819, 2.4233]
|
||||
- [1.701, 1.9859, 2.4337]
|
||||
- [1.7013, 1.9895, 2.4442]
|
||||
- [1.7016, 1.993, 2.4545]
|
||||
- [1.7017, 1.9963, 2.4643]
|
||||
- [1.7019, 1.9995, 2.4732]
|
||||
- [1.7019, 2.0024, 2.4815]
|
||||
- [1.702, 2.0049, 2.4893]
|
||||
- [1.702, 2.007, 2.4971]
|
||||
- [1.7021, 2.0089, 2.5051]
|
||||
- [1.7023, 2.0108, 2.5134]
|
||||
- [1.7026, 2.0127, 2.5215]
|
||||
- [1.7029, 2.0148, 2.5292]
|
||||
- [1.703, 2.0168, 2.5364]
|
||||
- [1.703, 2.0187, 2.5431]
|
||||
- [1.7029, 2.0204, 2.5494]
|
||||
- [1.7028, 2.0219, 2.5554]
|
||||
- [1.7027, 2.0232, 2.561]
|
||||
- [1.7028, 2.0246, 2.5662]
|
||||
- [1.7029, 2.026, 2.5711]
|
||||
- [1.7031, 2.0273, 2.5758]
|
||||
- [1.7032, 2.0286, 2.5805]
|
||||
- [1.7033, 2.0296, 2.5852]
|
||||
- [1.7034, 2.0305, 2.5898]
|
||||
- [1.7033, 2.0313, 2.594]
|
||||
- [1.7032, 2.032, 2.5976]
|
||||
- [1.7032, 2.0327, 2.6008]
|
||||
- [1.7032, 2.0334, 2.6038]
|
||||
- [1.7033, 2.0339, 2.6069]
|
||||
- [1.7034, 2.0344, 2.6102]
|
||||
- [1.7036, 2.0347, 2.6134]
|
||||
- [1.7038, 2.035, 2.6164]
|
||||
- [1.7038, 2.0354, 2.6188]
|
||||
- [1.7036, 2.0359, 2.6209]
|
||||
- [1.7034, 2.0365, 2.6228]
|
||||
- [1.7031, 2.0371, 2.6249]
|
||||
- [1.7029, 2.0376, 2.6273]
|
||||
- [1.7029, 2.0378, 2.6298]
|
||||
- [1.7032, 2.0379, 2.6319]
|
||||
- [1.7034, 2.0378, 2.6336]
|
||||
- [1.7036, 2.0378, 2.6348]
|
||||
- [1.7037, 2.038, 2.6358]
|
||||
- [1.7036, 2.0383, 2.6369]
|
||||
- [1.7034, 2.0387, 2.638]
|
||||
- [1.7033, 2.039, 2.6391]
|
||||
- [1.7032, 2.0393, 2.64]
|
||||
- [1.7032, 2.0395, 2.6407]
|
||||
- [1.7031, 2.0397, 2.6415]
|
||||
- [1.7031, 2.0398, 2.6423]
|
||||
@@ -1,452 +0,0 @@
|
||||
# Generated by tools/film-profiles/convert.py from spektrafilm.
|
||||
# Do not edit by hand: re-run the converter instead.
|
||||
#
|
||||
# spektrafilm by Andrea Volpato, https://github.com/andreavolpato/spektrafilm
|
||||
# Licensed CC BY-SA 4.0. Modified for DarkRoom: trimmed to the fields the
|
||||
# renderer uses and reformatted; see profiles/CHANGELOG.txt.
|
||||
|
||||
version: '0.3.2'
|
||||
stock: fujifilm_crystal_archive_typeii
|
||||
name: 'Fujifilm Crystal Archive Type II'
|
||||
kind: negative # negative | positive
|
||||
support: paper # film | paper
|
||||
stage: printing # filming | printing
|
||||
monochrome: false
|
||||
reference_illuminant: TH-KG3
|
||||
viewing_illuminant: D50
|
||||
|
||||
# log10 spectral sensitivity per layer, 380-780nm at 5nm, in R,G,B layer
|
||||
# order. A null upstream means the datasheet has no reading there, which is
|
||||
# blindness, so it is written as the sentinel the loader reads as such.
|
||||
log_sensitivity:
|
||||
- [-1.72849, -1.45889, -0.13565] # 380nm
|
||||
- [-1.72261, -1.4088, 0.0350273] # 385nm
|
||||
- [-1.71657, -1.35594, 0.175213] # 390nm
|
||||
- [-1.71034, -1.30008, 0.291354] # 395nm
|
||||
- [-1.70392, -1.24097, 0.385418] # 400nm
|
||||
- [-1.69731, -1.17833, 0.461728] # 405nm
|
||||
- [-1.69049, -1.11184, 0.487034] # 410nm
|
||||
- [-1.68345, -1.04117, 0.46685] # 415nm
|
||||
- [-1.67619, -0.965975, 0.466331] # 420nm
|
||||
- [-1.66869, -0.885921, 0.503006] # 425nm
|
||||
- [-1.66094, -0.800831, 0.582618] # 430nm
|
||||
- [-1.65292, -0.711205, 0.695305] # 435nm
|
||||
- [-1.64463, -0.622024, 0.798175] # 440nm
|
||||
- [-1.63605, -0.550974, 0.885224] # 445nm
|
||||
- [-1.62716, -0.471497, 0.958828] # 450nm
|
||||
- [-1.61795, -0.38529, 1.03405] # 455nm
|
||||
- [-1.6084, -0.293185, 1.12673] # 460nm
|
||||
- [-1.59849, -0.190272, 1.23891] # 465nm
|
||||
- [-1.5882, -0.107171, 1.38778] # 470nm
|
||||
- [-1.5775, -0.046512, 1.4762] # 475nm
|
||||
- [-1.56638, 0.00231205, 1.49213] # 480nm
|
||||
- [-1.5548, 0.0569909, 1.3993] # 485nm
|
||||
- [-1.54274, 0.157614, 1.20622] # 490nm
|
||||
- [-1.53016, 0.321573, 0.850708] # 495nm
|
||||
- [-1.51704, 0.48395, 0.432277] # 500nm
|
||||
- [-1.50334, 0.597413, 0.0438781] # 505nm
|
||||
- [-1.48902, 0.640533, -0.370317] # 510nm
|
||||
- [-1.47403, 0.61426, -0.680729] # 515nm
|
||||
- [-1.45832, 0.59313, -0.909975] # 520nm
|
||||
- [-1.44185, 0.611012, -1.20811] # 525nm
|
||||
- [-1.42456, 0.69349, -1.48636] # 530nm
|
||||
- [-1.40639, 0.851162, -1.74667] # 535nm
|
||||
- [-1.38725, 1.05477, -1.9907] # 540nm
|
||||
- [-1.36709, 1.18982, -2.21995] # 545nm
|
||||
- [-1.3458, 1.19734, -2.43571] # 550nm
|
||||
- [-1.32331, 0.9943, -2.63914] # 555nm
|
||||
- [-1.29949, 0.58033, -2.83127] # 560nm
|
||||
- [-1.27423, 0.102004, -3.01301] # 565nm
|
||||
- [-1.2474, -0.442731, -3.18519] # 570nm
|
||||
- [-1.21884, -0.962689, -3.34854] # 575nm
|
||||
- [-1.18839, -1.44551, -3.50372] # 580nm
|
||||
- [-1.15586, -1.89503, -3.65133] # 585nm
|
||||
- [-1.12103, -2.31458, -3.79191] # 590nm
|
||||
- [-1.08366, -2.70707, -3.92596] # 595nm
|
||||
- [-1.04348, -3.07502, -4.05391] # 600nm
|
||||
- [-1.00024, -3.42067, -4.17617] # 605nm
|
||||
- [-0.953788, -3.746, -4.29312] # 610nm
|
||||
- [-0.904925, -4.05273, -4.40509] # 615nm
|
||||
- [-0.856773, -4.34242, -4.5124] # 620nm
|
||||
- [-0.816642, -4.61645, -4.61533] # 625nm
|
||||
- [-0.786605, -4.87606, -4.71413] # 630nm
|
||||
- [-0.769419, -5.12236, -4.80907] # 635nm
|
||||
- [-0.758544, -5.35634, -4.90035] # 640nm
|
||||
- [-0.750889, -5.5789, -4.98819] # 645nm
|
||||
- [-0.740745, -5.79087, -5.07278] # 650nm
|
||||
- [-0.730874, -5.99298, -5.15429] # 655nm
|
||||
- [-0.714442, -6.18591, -5.23288] # 660nm
|
||||
- [-0.681737, -6.37026, -5.30872] # 665nm
|
||||
- [-0.620978, -6.54659, -5.38195] # 670nm
|
||||
- [-0.541223, -6.71542, -5.45269] # 675nm
|
||||
- [-0.452964, -6.87721, -5.52108] # 680nm
|
||||
- [-0.353734, -7.03241, -5.58722] # 685nm
|
||||
- [-0.25616, -7.18139, -5.65123] # 690nm
|
||||
- [-0.17291, -7.32453, -5.71321] # 695nm
|
||||
- [-0.0968648, -7.46217, -5.77325] # 700nm
|
||||
- [-0.0433942, -7.59461, -5.83144] # 705nm
|
||||
- [-0.0262128, -7.72215, -5.88787] # 710nm
|
||||
- [-0.03969, -7.84505, -5.94261] # 715nm
|
||||
- [-0.113391, -7.96356, -5.99575] # 720nm
|
||||
- [-0.315027, -8.07791, -6.04734] # 725nm
|
||||
- [-0.602976, -8.18832, -6.09747] # 730nm
|
||||
- [-0.877037, -8.29498, -6.14617] # 735nm
|
||||
- [-1.12917, -8.39809, -6.19353] # 740nm
|
||||
- [-1.36191, -8.49782, -6.23959] # 745nm
|
||||
- [-1.57741, -8.59433, -6.2844] # 750nm
|
||||
- [-1.77752, -8.68778, -6.32802] # 755nm
|
||||
- [-1.96383, -8.77831, -6.37049] # 760nm
|
||||
- [-2.13772, -8.86605, -6.41186] # 765nm
|
||||
- [-2.30038, -8.95114, -6.45217] # 770nm
|
||||
- [-2.45289, -9.03368, -6.49145] # 775nm
|
||||
- [-2.59615, -9.1138, -6.52976] # 780nm
|
||||
|
||||
# Spectral density of each layer's dye at unit density, same grid and order.
|
||||
dye_density:
|
||||
- [0.0535872, 0, 0.114736] # 380nm
|
||||
- [0.0516478, 0.0241619, 0.174544] # 385nm
|
||||
- [0.0485472, 0.0254428, 0.24179] # 390nm
|
||||
- [0.0493278, 0.0306765, 0.319245] # 395nm
|
||||
- [0.0476471, 0.0332242, 0.419029] # 400nm
|
||||
- [0.0476274, 0.0332242, 0.521266] # 405nm
|
||||
- [0.0476274, 0.0338556, 0.668001] # 410nm
|
||||
- [0.0476274, 0.0397069, 0.807391] # 415nm
|
||||
- [0.04723, 0.0452808, 0.91186] # 420nm
|
||||
- [0.0451642, 0.0504404, 1.00422] # 425nm
|
||||
- [0.0445558, 0.056064, 1.07683] # 430nm
|
||||
- [0.0438312, 0.0625845, 1.12534] # 435nm
|
||||
- [0.0426253, 0.0699821, 1.15527] # 440nm
|
||||
- [0.0404334, 0.079527, 1.16611] # 445nm
|
||||
- [0.0404056, 0.093543, 1.16214] # 450nm
|
||||
- [0.0404056, 0.107932, 1.14012] # 455nm
|
||||
- [0.0404056, 0.128165, 1.09253] # 460nm
|
||||
- [0.0404056, 0.152081, 1.02098] # 465nm
|
||||
- [0.0404056, 0.183751, 0.938491] # 470nm
|
||||
- [0.0404056, 0.216943, 0.813932] # 475nm
|
||||
- [0.0404056, 0.257291, 0.708028] # 480nm
|
||||
- [0.0404056, 0.306368, 0.582569] # 485nm
|
||||
- [0.0404056, 0.358964, 0.472806] # 490nm
|
||||
- [0.0411696, 0.426616, 0.368298] # 495nm
|
||||
- [0.0422358, 0.503746, 0.27099] # 500nm
|
||||
- [0.0451438, 0.590158, 0.204608] # 505nm
|
||||
- [0.0476214, 0.688945, 0.163098] # 510nm
|
||||
- [0.0491962, 0.779247, 0.130729] # 515nm
|
||||
- [0.0535149, 0.862578, 0.102252] # 520nm
|
||||
- [0.0547981, 0.955934, 0.0852359] # 525nm
|
||||
- [0.0622739, 1.04635, 0.0689451] # 530nm
|
||||
- [0.0732735, 1.11258, 0.05335] # 535nm
|
||||
- [0.0866949, 1.17013, 0.0465616] # 540nm
|
||||
- [0.104496, 1.19691, 0.0399187] # 545nm
|
||||
- [0.129517, 1.19737, 0.0338917] # 550nm
|
||||
- [0.15829, 1.16748, 0.030839] # 555nm
|
||||
- [0.196264, 1.09344, 0.0279583] # 560nm
|
||||
- [0.239355, 0.9867, 0.0257902] # 565nm
|
||||
- [0.281953, 0.848284, 0.0233344] # 570nm
|
||||
- [0.332883, 0.715483, 0.0233821] # 575nm
|
||||
- [0.397871, 0.567134, 0.0215211] # 580nm
|
||||
- [0.468423, 0.457659, 0.0193816] # 585nm
|
||||
- [0.549268, 0.346306, 0.0188885] # 590nm
|
||||
- [0.625449, 0.26265, 0.0151334] # 595nm
|
||||
- [0.704984, 0.19346, 0.0146367] # 600nm
|
||||
- [0.799658, 0.147851, 0.0146361] # 605nm
|
||||
- [0.886915, 0.117077, 0.0143639] # 610nm
|
||||
- [0.970277, 0.0857655, 0.0103382] # 615nm
|
||||
- [1.03751, 0.0674368, 0.00933611] # 620nm
|
||||
- [1.09096, 0.0536874, 0.00984592] # 625nm
|
||||
- [1.12505, 0.0423613, 0.0102343] # 630nm
|
||||
- [1.1432, 0.0319429, 0.0102343] # 635nm
|
||||
- [1.15079, 0.0256778, 0.00930957] # 640nm
|
||||
- [1.14756, 0.0199159, 0.00881458] # 645nm
|
||||
- [1.13655, 0.0161452, 0.00839374] # 650nm
|
||||
- [1.11889, 0.0146667, 0.00801033] # 655nm
|
||||
- [1.07863, 0.0141875, 0.00764148] # 660nm
|
||||
- [1.02102, 0.0138253, 0.00726431] # 665nm
|
||||
- [0.949384, 0.0129414, 0.00685595] # 670nm
|
||||
- [0.860726, 0.0118052, 0.00639354] # 675nm
|
||||
- [0.769831, 0.0106713, 0.00585421] # 680nm
|
||||
- [0.693466, 0.00953339, 0.00526879] # 685nm
|
||||
- [0.628063, 0.00838512, 0.00468337] # 690nm
|
||||
- [0.574846, 0.00722018, 0.00409795] # 695nm
|
||||
- [0.520894, 0.00603227, 0.00351253] # 700nm
|
||||
- [0.482782, 0.00483379, 0.0029271] # 705nm
|
||||
- [0.442446, 0.00363568, 0.00234168] # 710nm
|
||||
- [0.40718, 0.00242941, 0.00175626] # 715nm
|
||||
- [0.372016, 0.00120645, 0.00117084] # 720nm
|
||||
- [0, 0, 0] # 725nm
|
||||
- [0, 0, 0] # 730nm
|
||||
- [0, 0, 0] # 735nm
|
||||
- [0, 0, 0] # 740nm
|
||||
- [0, 0, 0] # 745nm
|
||||
- [0, 0, 0] # 750nm
|
||||
- [0, 0, 0] # 755nm
|
||||
- [0, 0, 0] # 760nm
|
||||
- [0, 0, 0] # 765nm
|
||||
- [0, 0, 0] # 770nm
|
||||
- [0, 0, 0] # 775nm
|
||||
- [0, 0, 0] # 780nm
|
||||
|
||||
# The support's own density -- film base plus, for a colour negative, the
|
||||
# orange mask. Flat zero where the datasheet does not give it.
|
||||
base_density: [0.0854183, 0.0854181, 0.0854178, 0.0854172, 0.0854159, 0.0854133, 0.0854087, 0.0854008, 0.085388, 0.0853681, 0.085339, 0.0852984, 0.0852443, 0.0851754, 0.0850913, 0.0849923, 0.0848799, 0.084756, 0.0846228, 0.0844827, 0.0843378, 0.0841898, 0.0840402, 0.0838901, 0.0837405, 0.0835924, 0.0834466, 0.0833041, 0.0831656, 0.083032, 0.0829037, 0.0827808, 0.0826633, 0.0825507, 0.0824422, 0.0823372, 0.0822349, 0.0821348, 0.0820367, 0.0819406, 0.0818471, 0.081757, 0.0816715, 0.081592, 0.0815199, 0.0814565, 0.0814026, 0.0813584, 0.0813237, 0.0812977, 0.081279, 0.0812663, 0.0812581, 0.081253, 0.0812501, 0.0812484, 0.0812476, 0.0812472, 0.081247, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469, 0.0812469]
|
||||
|
||||
# The characteristic curves: density against log10 exposure, sampled
|
||||
# uniformly over [-3, 4].
|
||||
log_exposure_min: -3
|
||||
log_exposure_max: 4
|
||||
density_curves:
|
||||
- [-0.00082207, -0.00082089, -0.0008278]
|
||||
- [-0.00070251, -0.00068042, -0.00074299]
|
||||
- [-0.00050646, -0.00043818, -0.00059421]
|
||||
- [-0.00029511, -0.00018684, -0.00040366]
|
||||
- [-0.00012342, -3.2448e-05, -0.00017405]
|
||||
- [-5.7059e-06, -1.3791e-05, 8.2719e-05]
|
||||
- [9.1999e-05, -5.3052e-05, 0.00030861]
|
||||
- [0.00021437, -1.8094e-05, 0.00041835]
|
||||
- [0.00036418, 0.00014486, 0.00037072]
|
||||
- [0.00048967, 0.00034766, 0.00021435]
|
||||
- [0.00052986, 0.00044217, 4.9948e-05]
|
||||
- [0.00046977, 0.0003613, -5.334e-05]
|
||||
- [0.00032883, 0.00015162, -7.5506e-05]
|
||||
- [0.00015441, -7.0724e-05, -3.2627e-05]
|
||||
- [-2.3977e-06, -0.00019632, 4.8881e-05]
|
||||
- [-0.00010783, -0.00018362, 0.00014965]
|
||||
- [-0.00014853, -6.686e-05, 0.00025336]
|
||||
- [-0.00012144, 8.2629e-05, 0.00033274]
|
||||
- [-2.6361e-05, 0.00020353, 0.00034796]
|
||||
- [0.00012767, 0.00026558, 0.00026816]
|
||||
- [0.00030747, 0.00026076, 0.00010248]
|
||||
- [0.00045058, 0.00018801, -8.7828e-05]
|
||||
- [0.00048483, 5.4486e-05, -0.00021447]
|
||||
- [0.00036793, -0.00010806, -0.0002141]
|
||||
- [0.00012262, -0.00023675, -9.1985e-05]
|
||||
- [-0.00016103, -0.00026207, 7.6502e-05]
|
||||
- [-0.00036566, -0.00015576, 0.00019237]
|
||||
- [-0.00040922, 3.4806e-05, 0.0001975]
|
||||
- [-0.00029184, 0.00020223, 0.0001122]
|
||||
- [-9.5598e-05, 0.0002456, 1.8958e-05]
|
||||
- [6.4346e-05, 0.00014515, 3.3838e-06]
|
||||
- [0.00010726, -1.3323e-05, 9.4186e-05]
|
||||
- [3.073e-05, -9.0986e-05, 0.00024356]
|
||||
- [-9.5183e-05, 4.539e-06, 0.00035985]
|
||||
- [-0.00017677, 0.0002432, 0.00036739]
|
||||
- [-0.00015513, 0.00047913, 0.00025196]
|
||||
- [-3.7734e-05, 0.00054189, 6.3882e-05]
|
||||
- [0.00011276, 0.00035217, -0.00011721]
|
||||
- [0.00021999, -1.8483e-05, -0.00022712]
|
||||
- [0.00023667, -0.00038648, -0.00024212]
|
||||
- [0.00016448, -0.00056744, -0.00017543]
|
||||
- [4.5715e-05, -0.00048499, -5.8608e-05]
|
||||
- [-6.1191e-05, -0.00020712, 7.2192e-05]
|
||||
- [-0.00010547, 0.00010637, 0.00017789]
|
||||
- [-5.8446e-05, 0.00030226, 0.00021564]
|
||||
- [7.9802e-05, 0.00031088, 0.00014983]
|
||||
- [0.0002772, 0.00016015, -2.3921e-05]
|
||||
- [0.00047128, -6.5728e-05, -0.00025896]
|
||||
- [0.00058293, -0.00028098, -0.00046471]
|
||||
- [0.00054165, -0.00042321, -0.00055092]
|
||||
- [0.0003253, -0.00047619, -0.00049915]
|
||||
- [7.3861e-06, -0.00044152, -0.00034298]
|
||||
- [-0.00025809, -0.00030076, -0.00010896]
|
||||
- [-0.00033976, -6.2071e-05, 0.00016363]
|
||||
- [-0.00023734, 0.00017096, 0.00037558]
|
||||
- [-7.8859e-05, 0.00021856, 0.00040885]
|
||||
- [-4.5496e-06, -3.7942e-05, 0.00024981]
|
||||
- [-5.8464e-05, -0.00051429, 4.6653e-05]
|
||||
- [-0.00019127, -0.00093498, -1.538e-06]
|
||||
- [-0.00034349, -0.001037, 0.0001544]
|
||||
- [-0.00044273, -0.00073061, 0.0003845]
|
||||
- [-0.00042628, -0.00014786, 0.00050558]
|
||||
- [-0.00026692, 0.00044082, 0.00041417]
|
||||
- [6.5451e-06, 0.00079889, 0.00014715]
|
||||
- [0.00030202, 0.00086277, -0.00016471]
|
||||
- [0.00048658, 0.00075004, -0.00040025]
|
||||
- [0.0004445, 0.00063751, -0.0005207]
|
||||
- [0.00014892, 0.00060848, -0.00055964]
|
||||
- [-0.00029785, 0.00059124, -0.00055381]
|
||||
- [-0.00069475, 0.00043711, -0.00048792]
|
||||
- [-0.00084181, 7.3688e-05, -0.00030917]
|
||||
- [-0.00065716, -0.00039517, -8.8031e-08]
|
||||
- [-0.00023585, -0.00073202, 0.00035941]
|
||||
- [0.00019663, -0.00072803, 0.00060765]
|
||||
- [0.0004156, -0.00036241, 0.00059894]
|
||||
- [0.00033322, 0.00016763, 0.00031642]
|
||||
- [5.0392e-05, 0.00057689, -8.6933e-05]
|
||||
- [-0.00020994, 0.00068893, -0.00035704]
|
||||
- [-0.00024236, 0.00055285, -0.00027401]
|
||||
- [2.4271e-05, 0.00039345, 0.00023857]
|
||||
- [0.00051088, 0.00043678, 0.0011028]
|
||||
- [0.0010787, 0.00074961, 0.0021741]
|
||||
- [0.0016567, 0.0012202, 0.0033602]
|
||||
- [0.0023139, 0.0016976, 0.0046902]
|
||||
- [0.0032291, 0.0021826, 0.0063003]
|
||||
- [0.0045891, 0.002919, 0.0083685]
|
||||
- [0.0064976, 0.0043089, 0.011067]
|
||||
- [0.0089716, 0.006716, 0.014575]
|
||||
- [0.012067, 0.010247, 0.019105]
|
||||
- [0.01599, 0.014723, 0.024907]
|
||||
- [0.021128, 0.019911, 0.032266]
|
||||
- [0.027967, 0.025845, 0.041485]
|
||||
- [0.03695, 0.032967, 0.052808]
|
||||
- [0.048361, 0.041996, 0.066279]
|
||||
- [0.062311, 0.053595, 0.081691]
|
||||
- [0.078811, 0.068073, 0.098725]
|
||||
- [0.097885, 0.085337, 0.11724]
|
||||
- [0.11966, 0.10507, 0.13741]
|
||||
- [0.14433, 0.12704, 0.15951]
|
||||
- [0.17206, 0.15127, 0.18372]
|
||||
- [0.20283, 0.17805, 0.21003]
|
||||
- [0.23646, 0.20762, 0.23829]
|
||||
- [0.27278, 0.23997, 0.26842]
|
||||
- [0.31188, 0.27478, 0.30059]
|
||||
- [0.35419, 0.31171, 0.33524]
|
||||
- [0.40038, 0.35077, 0.37288]
|
||||
- [0.45105, 0.39251, 0.41394]
|
||||
- [0.50649, 0.43786, 0.45877]
|
||||
- [0.56669, 0.48776, 0.50778]
|
||||
- [0.63151, 0.54281, 0.56172]
|
||||
- [0.70116, 0.60317, 0.62176]
|
||||
- [0.77634, 0.66872, 0.6891]
|
||||
- [0.85811, 0.73927, 0.76438]
|
||||
- [0.94732, 0.81458, 0.84722]
|
||||
- [1.0441, 0.89415, 0.93616]
|
||||
- [1.1475, 0.97724, 1.0289]
|
||||
- [1.256, 1.0629, 1.1231]
|
||||
- [1.3675, 1.1495, 1.2169]
|
||||
- [1.4791, 1.2351, 1.3092]
|
||||
- [1.5884, 1.3179, 1.3995]
|
||||
- [1.6936, 1.3972, 1.486]
|
||||
- [1.7936, 1.4734, 1.5661]
|
||||
- [1.8874, 1.5464, 1.6374]
|
||||
- [1.9732, 1.6148, 1.6991]
|
||||
- [2.0497, 1.6771, 1.7515]
|
||||
- [2.1163, 1.7325, 1.7958]
|
||||
- [2.1736, 1.7817, 1.8327]
|
||||
- [2.2226, 1.8263, 1.8629]
|
||||
- [2.2648, 1.8682, 1.8866]
|
||||
- [2.3009, 1.9079, 1.9046]
|
||||
- [2.3314, 1.9453, 1.918]
|
||||
- [2.3563, 1.9795, 1.928]
|
||||
- [2.3764, 2.0099, 1.9358]
|
||||
- [2.3922, 2.0365, 1.9419]
|
||||
- [2.4046, 2.0599, 1.9466]
|
||||
- [2.4145, 2.0809, 1.9501]
|
||||
- [2.4223, 2.0997, 1.9524]
|
||||
- [2.4283, 2.1163, 1.954]
|
||||
- [2.4329, 2.1306, 1.9553]
|
||||
- [2.4362, 2.1424, 1.9565]
|
||||
- [2.4386, 2.152, 1.9577]
|
||||
- [2.4403, 2.1595, 1.9585]
|
||||
- [2.4415, 2.1653, 1.9588]
|
||||
- [2.4426, 2.1698, 1.9584]
|
||||
- [2.4434, 2.1733, 1.9577]
|
||||
- [2.444, 2.1759, 1.9571]
|
||||
- [2.4443, 2.1779, 1.9569]
|
||||
- [2.4444, 2.1793, 1.9571]
|
||||
- [2.4446, 2.1805, 1.9576]
|
||||
- [2.4448, 2.1814, 1.958]
|
||||
- [2.445, 2.1822, 1.9582]
|
||||
- [2.4452, 2.1828, 1.9581]
|
||||
- [2.4452, 2.1833, 1.9579]
|
||||
- [2.4453, 2.1835, 1.9578]
|
||||
- [2.4453, 2.1836, 1.9579]
|
||||
- [2.4453, 2.1837, 1.9579]
|
||||
- [2.4453, 2.1838, 1.9579]
|
||||
- [2.4452, 2.184, 1.9576]
|
||||
- [2.4449, 2.184, 1.9573]
|
||||
- [2.4447, 2.1839, 1.9569]
|
||||
- [2.4447, 2.1838, 1.9568]
|
||||
- [2.445, 2.1838, 1.957]
|
||||
- [2.4455, 2.1839, 1.9574]
|
||||
- [2.446, 2.1843, 1.9578]
|
||||
- [2.4461, 2.1847, 1.9581]
|
||||
- [2.4458, 2.185, 1.9582]
|
||||
- [2.4453, 2.1851, 1.9582]
|
||||
- [2.4449, 2.1849, 1.9582]
|
||||
- [2.445, 2.1844, 1.9583]
|
||||
- [2.4455, 2.184, 1.9584]
|
||||
- [2.4459, 2.1838, 1.9584]
|
||||
- [2.4459, 2.1838, 1.9581]
|
||||
- [2.4455, 2.1839, 1.9578]
|
||||
- [2.4451, 2.1841, 1.9574]
|
||||
- [2.4451, 2.1843, 1.9572]
|
||||
- [2.4454, 2.1843, 1.9573]
|
||||
- [2.4458, 2.1843, 1.9575]
|
||||
- [2.446, 2.184, 1.9577]
|
||||
- [2.4456, 2.1837, 1.9579]
|
||||
- [2.4449, 2.1835, 1.9578]
|
||||
- [2.4444, 2.1834, 1.9577]
|
||||
- [2.4444, 2.1836, 1.9575]
|
||||
- [2.445, 2.184, 1.9575]
|
||||
- [2.4456, 2.1843, 1.9576]
|
||||
- [2.4458, 2.1844, 1.9579]
|
||||
- [2.4453, 2.1842, 1.9581]
|
||||
- [2.4443, 2.184, 1.9581]
|
||||
- [2.4436, 2.1838, 1.9579]
|
||||
- [2.4437, 2.1839, 1.9576]
|
||||
- [2.4444, 2.1842, 1.9574]
|
||||
- [2.4455, 2.1844, 1.9574]
|
||||
- [2.4461, 2.1844, 1.9576]
|
||||
- [2.446, 2.1842, 1.9579]
|
||||
- [2.4454, 2.1839, 1.9581]
|
||||
- [2.4448, 2.1837, 1.958]
|
||||
- [2.4446, 2.1839, 1.9578]
|
||||
- [2.445, 2.1844, 1.9575]
|
||||
- [2.4457, 2.1849, 1.9574]
|
||||
- [2.4461, 2.185, 1.9575]
|
||||
- [2.4461, 2.1846, 1.9577]
|
||||
- [2.4456, 2.1838, 1.958]
|
||||
- [2.445, 2.1831, 1.9581]
|
||||
- [2.4446, 2.1827, 1.9582]
|
||||
- [2.4444, 2.1829, 1.9583]
|
||||
- [2.4444, 2.1833, 1.9584]
|
||||
- [2.4447, 2.1838, 1.9583]
|
||||
- [2.4451, 2.1842, 1.958]
|
||||
- [2.4454, 2.1844, 1.9578]
|
||||
- [2.4456, 2.1845, 1.9577]
|
||||
- [2.4457, 2.1845, 1.9578]
|
||||
- [2.4457, 2.1846, 1.9579]
|
||||
- [2.4456, 2.1847, 1.958]
|
||||
- [2.4455, 2.1849, 1.9579]
|
||||
- [2.4454, 2.1849, 1.9576]
|
||||
- [2.4452, 2.1847, 1.9572]
|
||||
- [2.4451, 2.1844, 1.9569]
|
||||
- [2.4451, 2.184, 1.9569]
|
||||
- [2.4452, 2.1837, 1.9572]
|
||||
- [2.4453, 2.1835, 1.9575]
|
||||
- [2.4455, 2.1836, 1.9578]
|
||||
- [2.4456, 2.1837, 1.958]
|
||||
- [2.4455, 2.1838, 1.958]
|
||||
- [2.4453, 2.1839, 1.9581]
|
||||
- [2.4451, 2.1838, 1.9581]
|
||||
- [2.4449, 2.1838, 1.9581]
|
||||
- [2.4449, 2.1838, 1.9579]
|
||||
- [2.445, 2.184, 1.9578]
|
||||
- [2.4452, 2.1842, 1.9576]
|
||||
- [2.4453, 2.1843, 1.9576]
|
||||
- [2.4454, 2.1844, 1.9578]
|
||||
- [2.4454, 2.1844, 1.958]
|
||||
- [2.4453, 2.1843, 1.9581]
|
||||
- [2.4452, 2.1842, 1.958]
|
||||
- [2.4451, 2.1842, 1.9577]
|
||||
- [2.445, 2.1842, 1.9574]
|
||||
- [2.4451, 2.1842, 1.9573]
|
||||
- [2.4453, 2.184, 1.9575]
|
||||
- [2.4456, 2.1838, 1.9578]
|
||||
- [2.4457, 2.1837, 1.958]
|
||||
- [2.4457, 2.1836, 1.9579]
|
||||
- [2.4455, 2.1837, 1.9577]
|
||||
- [2.4452, 2.184, 1.9574]
|
||||
- [2.4449, 2.1842, 1.9575]
|
||||
- [2.4447, 2.1844, 1.9578]
|
||||
- [2.4447, 2.1843, 1.9582]
|
||||
- [2.4449, 2.1841, 1.9586]
|
||||
- [2.4452, 2.1838, 1.9587]
|
||||
- [2.4455, 2.1836, 1.9585]
|
||||
- [2.4455, 2.1835, 1.9581]
|
||||
- [2.4454, 2.1837, 1.9579]
|
||||
- [2.4452, 2.1839, 1.9579]
|
||||
- [2.445, 2.1841, 1.9578]
|
||||
- [2.445, 2.1843, 1.9577]
|
||||
- [2.4449, 2.1844, 1.9576]
|
||||
- [2.4448, 2.1845, 1.9574]
|
||||
- [2.4448, 2.1845, 1.9574]
|
||||
@@ -1,453 +0,0 @@
|
||||
# Generated by tools/film-profiles/convert.py from spektrafilm.
|
||||
# Do not edit by hand: re-run the converter instead.
|
||||
#
|
||||
# spektrafilm by Andrea Volpato, https://github.com/andreavolpato/spektrafilm
|
||||
# Licensed CC BY-SA 4.0. Modified for DarkRoom: trimmed to the fields the
|
||||
# renderer uses and reformatted; see profiles/CHANGELOG.txt.
|
||||
|
||||
version: '0.3.2'
|
||||
stock: fujifilm_pro_400h
|
||||
name: 'Fujifilm Pro 400H'
|
||||
kind: negative # negative | positive
|
||||
support: film # film | paper
|
||||
stage: filming # filming | printing
|
||||
monochrome: false
|
||||
reference_illuminant: D55
|
||||
viewing_illuminant: D50
|
||||
target_print: fujifilm_crystal_archive_typeii
|
||||
|
||||
# log10 spectral sensitivity per layer, 380-780nm at 5nm, in R,G,B layer
|
||||
# order. A null upstream means the datasheet has no reading there, which is
|
||||
# blindness, so it is written as the sentinel the loader reads as such.
|
||||
log_sensitivity:
|
||||
- [-4.74895, -3.57065, -3.01142] # 380nm
|
||||
- [-4.72066, -3.51638, -2.51188] # 385nm
|
||||
- [-4.69133, -3.45942, -2.06558] # 390nm
|
||||
- [-4.6609, -3.39957, -1.53059] # 395nm
|
||||
- [-4.62929, -3.3366, -1.01396] # 400nm
|
||||
- [-4.59644, -3.27026, -0.666879] # 405nm
|
||||
- [-4.56228, -3.20031, -0.510879] # 410nm
|
||||
- [-4.52672, -3.12643, -0.459432] # 415nm
|
||||
- [-4.48969, -3.04832, -0.442987] # 420nm
|
||||
- [-4.45107, -2.96565, -0.442142] # 425nm
|
||||
- [-4.41078, -2.87806, -0.448563] # 430nm
|
||||
- [-4.3687, -2.78525, -0.4594] # 435nm
|
||||
- [-4.32471, -2.68702, -0.471909] # 440nm
|
||||
- [-4.27866, -2.58361, -0.482312] # 445nm
|
||||
- [-4.23043, -2.47694, -0.481095] # 450nm
|
||||
- [-4.17984, -2.37947, -0.464556] # 455nm
|
||||
- [-4.12673, -2.30961, -0.410612] # 460nm
|
||||
- [-4.07089, -2.23696, -0.326741] # 465nm
|
||||
- [-4.01211, -2.1604, -0.268736] # 470nm
|
||||
- [-3.95015, -2.03189, -0.360243] # 475nm
|
||||
- [-3.88475, -1.83324, -0.65128] # 480nm
|
||||
- [-3.81562, -1.60727, -1.02366] # 485nm
|
||||
- [-3.74241, -1.37249, -1.38035] # 490nm
|
||||
- [-3.66478, -1.15772, -1.63592] # 495nm
|
||||
- [-3.58228, -0.966348, -1.86046] # 500nm
|
||||
- [-3.49447, -0.853158, -2.06649] # 505nm
|
||||
- [-3.4008, -0.761958, -2.24571] # 510nm
|
||||
- [-3.30068, -0.668991, -2.41068] # 515nm
|
||||
- [-3.1934, -0.586303, -2.60081] # 520nm
|
||||
- [-3.07817, -0.510941, -2.78935] # 525nm
|
||||
- [-2.95408, -0.447353, -2.96779] # 530nm
|
||||
- [-2.82007, -0.400557, -3.13545] # 535nm
|
||||
- [-2.67488, -0.362668, -3.29283] # 540nm
|
||||
- [-2.51707, -0.332829, -3.44068] # 545nm
|
||||
- [-2.34492, -0.314016, -3.57975] # 550nm
|
||||
- [-2.15636, -0.310583, -3.71076] # 555nm
|
||||
- [-1.94896, -0.343411, -3.83436] # 560nm
|
||||
- [-1.76603, -0.438711, -3.95115] # 565nm
|
||||
- [-1.56821, -0.59746, -4.06167] # 570nm
|
||||
- [-1.31267, -0.87501, -4.1664] # 575nm
|
||||
- [-1.07494, -1.20135, -4.26578] # 580nm
|
||||
- [-0.851192, -1.58855, -4.36022] # 585nm
|
||||
- [-0.654707, -2.02843, -4.45006] # 590nm
|
||||
- [-0.479111, -2.449, -4.53564] # 595nm
|
||||
- [-0.354439, -2.84057, -4.61724] # 600nm
|
||||
- [-0.300304, -3.20604, -4.69515] # 605nm
|
||||
- [-0.286695, -3.54792, -4.76959] # 610nm
|
||||
- [-0.29805, -3.86844, -4.84081] # 615nm
|
||||
- [-0.301413, -4.16953, -4.909] # 620nm
|
||||
- [-0.291668, -4.45291, -4.97435] # 625nm
|
||||
- [-0.288952, -4.7201, -5.03704] # 630nm
|
||||
- [-0.313799, -4.97245, -5.09723] # 635nm
|
||||
- [-0.392054, -5.21115, -5.15506] # 640nm
|
||||
- [-0.589146, -5.43729, -5.21066] # 645nm
|
||||
- [-0.872896, -5.65184, -5.26417] # 650nm
|
||||
- [-1.20557, -5.85565, -5.3157] # 655nm
|
||||
- [-1.56468, -6.04953, -5.36535] # 660nm
|
||||
- [-1.90409, -6.23417, -5.41324] # 665nm
|
||||
- [-2.21264, -6.41023, -5.45944] # 670nm
|
||||
- [-2.49436, -6.57828, -5.50406] # 675nm
|
||||
- [-2.75261, -6.73886, -5.54716] # 680nm
|
||||
- [-2.9902, -6.89246, -5.58882] # 685nm
|
||||
- [-3.20951, -7.03953, -5.62913] # 690nm
|
||||
- [-3.41258, -7.18046, -5.66813] # 695nm
|
||||
- [-3.60114, -7.31565, -5.70589] # 700nm
|
||||
- [-3.77669, -7.44543, -5.74247] # 705nm
|
||||
- [-3.94055, -7.57011, -5.77793] # 710nm
|
||||
- [-4.09383, -7.69001, -5.81232] # 715nm
|
||||
- [-4.23753, -7.80537, -5.84568] # 720nm
|
||||
- [-4.37253, -7.91647, -5.87806] # 725nm
|
||||
- [-4.49958, -8.02352, -5.9095] # 730nm
|
||||
- [-4.61937, -8.12676, -5.94004] # 735nm
|
||||
- [-4.73251, -8.22637, -5.96972] # 740nm
|
||||
- [-4.83953, -8.32254, -5.99858] # 745nm
|
||||
- [-4.94092, -8.41546, -6.02665] # 750nm
|
||||
- [-5.03711, -8.50527, -6.05396] # 755nm
|
||||
- [-5.12848, -8.59215, -6.08054] # 760nm
|
||||
- [-5.21541, -8.67622, -6.10642] # 765nm
|
||||
- [-5.29819, -8.75762, -6.13163] # 770nm
|
||||
- [-5.37712, -8.83648, -6.15619] # 775nm
|
||||
- [-5.45247, -8.91291, -6.18013] # 780nm
|
||||
|
||||
# Spectral density of each layer's dye at unit density, same grid and order.
|
||||
dye_density:
|
||||
- [0, 0, 0] # 380nm
|
||||
- [0.34676, 0.0696513, 0.131702] # 385nm
|
||||
- [0.319265, 0.0618921, 0.225988] # 390nm
|
||||
- [0.285859, 0.0537333, 0.312971] # 395nm
|
||||
- [0.245892, 0.0455686, 0.397832] # 400nm
|
||||
- [0.201381, 0.0377528, 0.486404] # 405nm
|
||||
- [0.156198, 0.0302909, 0.581067] # 410nm
|
||||
- [0.114163, 0.0227371, 0.680427] # 415nm
|
||||
- [0.0776462, 0.0143654, 0.781391] # 420nm
|
||||
- [0.0474829, 0.00448927, 0.880456] # 425nm
|
||||
- [0.0236701, -0.00722403, 0.97363] # 430nm
|
||||
- [0.00596488, -0.0204432, 1.05624] # 435nm
|
||||
- [-0.00596592, -0.0338814, 1.12347] # 440nm
|
||||
- [-0.0125814, -0.0452131, 1.17152] # 445nm
|
||||
- [-0.0144918, -0.051544, 1.19796] # 450nm
|
||||
- [-0.0125676, -0.0503589, 1.20113] # 455nm
|
||||
- [-0.00789912, -0.0401265, 1.17964] # 460nm
|
||||
- [-0.00159968, -0.0196582, 1.13293] # 465nm
|
||||
- [0.00535289, 0.0132097, 1.06189] # 470nm
|
||||
- [0.0122441, 0.0616617, 0.96931] # 475nm
|
||||
- [0.0186806, 0.128136, 0.859798] # 480nm
|
||||
- [0.0245206, 0.21287, 0.739344] # 485nm
|
||||
- [0.0297383, 0.314177, 0.614261] # 490nm
|
||||
- [0.0343113, 0.429397, 0.490143] # 495nm
|
||||
- [0.0382038, 0.554954, 0.371861] # 500nm
|
||||
- [0.0413269, 0.685933, 0.264014] # 505nm
|
||||
- [0.0434352, 0.81654, 0.170692] # 510nm
|
||||
- [0.0440126, 0.941437, 0.0947287] # 515nm
|
||||
- [0.0422939, 1.05641, 0.0371587] # 520nm
|
||||
- [0.0374526, 1.15812, -0.00273735] # 525nm
|
||||
- [0.0288374, 1.2434, -0.0270147] # 530nm
|
||||
- [0.0162005, 1.30839, -0.0384658] # 535nm
|
||||
- [-3.51948e-06, 1.34857, -0.0400844] # 540nm
|
||||
- [-0.0182628, 1.36054, -0.0348706] # 545nm
|
||||
- [-0.0357715, 1.34363, -0.0257769] # 550nm
|
||||
- [-0.0485783, 1.29962, -0.0154197] # 555nm
|
||||
- [-0.0522567, 1.23113, -0.00566218] # 560nm
|
||||
- [-0.0428337, 1.1409, 0.0024825] # 565nm
|
||||
- [-0.0175538, 1.03254, 0.00863996] # 570nm
|
||||
- [0.0245851, 0.911548, 0.0128401] # 575nm
|
||||
- [0.0825216, 0.78539, 0.0153817] # 580nm
|
||||
- [0.153172, 0.662055, 0.0167387] # 585nm
|
||||
- [0.231975, 0.54781, 0.0174185] # 590nm
|
||||
- [0.313945, 0.446096, 0.0177523] # 595nm
|
||||
- [0.395032, 0.358263, 0.0177606] # 600nm
|
||||
- [0.473009, 0.284591, 0.0172294] # 605nm
|
||||
- [0.5473, 0.224545, 0.0159148] # 610nm
|
||||
- [0.618185, 0.176674, 0.0137418] # 615nm
|
||||
- [0.686086, 0.138752, 0.0109325] # 620nm
|
||||
- [0.751414, 0.108374, 0.00799496] # 625nm
|
||||
- [0.814815, 0.0837329, 0.00550782] # 630nm
|
||||
- [0.877119, 0.0638285, 0.00378755] # 635nm
|
||||
- [0.938785, 0.0480442, 0.00275588] # 640nm
|
||||
- [0.999514, 0.0357303, 0.00213569] # 645nm
|
||||
- [1.05863, 0.0261626, 0.00168749] # 650nm
|
||||
- [1.11569, 0.0186805, 0.00129608] # 655nm
|
||||
- [1.17042, 0.0128209, 0.000939411] # 660nm
|
||||
- [1.22221, 0.00835647, 0.00063108] # 665nm
|
||||
- [1.26972, 0.0052109, 0.000384139] # 670nm
|
||||
- [1.31099, 0.00333637, 0.000199476] # 675nm
|
||||
- [1.34422, 0.00263613, 6.65128e-05] # 680nm
|
||||
- [1.36871, 0.00294955, -3.79186e-11] # 685nm
|
||||
- [1.38485, 0.00406796, -9.61671e-12] # 690nm
|
||||
- [1.39294, 0.00577614, -2.34088e-12] # 695nm
|
||||
- [1.39221, 0.00788678, -5.46902e-13] # 700nm
|
||||
- [1.38102, 0.0102338, -1.22636e-13] # 705nm
|
||||
- [1.35783, 0.0126326, -2.6394e-14] # 710nm
|
||||
- [1.32208, 0.014925, -5.4522e-15] # 715nm
|
||||
- [1.27458, 0.0170359, -1.08098e-15] # 720nm
|
||||
- [1.21726, 0.0189471, -2.05702e-16] # 725nm
|
||||
- [1.15236, 0.0206701, -3.75698e-17] # 730nm
|
||||
- [1.08185, 0.022228, 0.000136517] # 735nm
|
||||
- [1.00746, 0.0236499, 0.000234804] # 740nm
|
||||
- [0.930904, 0.0249707, 0.000254194] # 745nm
|
||||
- [0, 0.0262329, 0.000154766] # 750nm
|
||||
- [0, 0, 0] # 755nm
|
||||
- [0, 0, 0] # 760nm
|
||||
- [0, 0, 0] # 765nm
|
||||
- [0, 0, 0] # 770nm
|
||||
- [0, 0, 0] # 775nm
|
||||
- [0, 0, 0] # 780nm
|
||||
|
||||
# The support's own density -- film base plus, for a colour negative, the
|
||||
# orange mask. Flat zero where the datasheet does not give it.
|
||||
base_density: [0, 0, 0, 0, 1.4474, 1.20509, 1.07983, 1.04147, 1.03163, 1.03439, 1.0368, 1.03634, 1.0304, 1.02041, 1.00575, 0.993828, 0.977195, 0.958985, 0.940514, 0.921349, 0.901653, 0.881819, 0.862021, 0.842933, 0.826213, 0.81326, 0.801029, 0.788723, 0.776385, 0.763877, 0.751139, 0.738038, 0.724336, 0.709406, 0.693362, 0.684193, 0.676051, 0.666831, 0.652116, 0.616376, 0.560767, 0.49031, 0.416583, 0.354486, 0.297783, 0.25329, 0.223723, 0.20895, 0.203568, 0.201114, 0.200084, 0.200073, 0.200496, 0.201116, 0.201074, 0.198929, 0.196057, 0.193024, 0.189976, 0.186897, 0.183803, 0.180708, 0.177659, 0.174625, 0.17126, 0.168754, 0.16604, 0.163724, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
|
||||
# The characteristic curves: density against log10 exposure, sampled
|
||||
# uniformly over [-3, 4].
|
||||
log_exposure_min: -3
|
||||
log_exposure_max: 4
|
||||
density_curves:
|
||||
- [-0.00063116, -0.00059043, -0.00068093]
|
||||
- [-0.00053763, -0.00048781, -0.00061044]
|
||||
- [-0.00038468, -0.00031126, -0.00048704]
|
||||
- [-0.00021946, -0.00012775, -0.00032896]
|
||||
- [-8.437e-05, -1.3915e-05, -0.00013838]
|
||||
- [9.655e-06, 2.2463e-06, 7.4954e-05]
|
||||
- [8.9022e-05, -2.2833e-05, 0.00026328]
|
||||
- [0.00018848, 6.6842e-06, 0.00035633]
|
||||
- [0.00031043, 0.00013001, 0.00032019]
|
||||
- [0.00041512, 0.00028363, 0.0001951]
|
||||
- [0.00045563, 0.00036048, 6.4328e-05]
|
||||
- [0.00042065, 0.00031212, -1.4781e-05]
|
||||
- [0.00032554, 0.00017257, -2.5369e-05]
|
||||
- [0.00020746, 2.6488e-05, 1.9705e-05]
|
||||
- [0.00010665, -4.601e-05, 9.9106e-05]
|
||||
- [5.0155e-05, -1.3752e-05, 0.00019739]
|
||||
- [4.9427e-05, 9.9527e-05, 0.00030175]
|
||||
- [0.00010815, 0.00024311, 0.00039033]
|
||||
- [0.00022807, 0.00037349, 0.00043093]
|
||||
- [0.00040401, 0.0004698, 0.00039875]
|
||||
- [0.00061205, 0.0005275, 0.00030224]
|
||||
- [0.00080522, 0.00054723, 0.00019351]
|
||||
- [0.00092835, 0.00053591, 0.00014783]
|
||||
- [0.00094931, 0.00051896, 0.00022058]
|
||||
- [0.00088741, 0.0005458, 0.00041049]
|
||||
- [0.00081658, 0.00067221, 0.00065769]
|
||||
- [0.0008349, 0.00092463, 0.00088194]
|
||||
- [0.0010148, 0.0012733, 0.0010368]
|
||||
- [0.0013641, 0.001642, 0.0011422]
|
||||
- [0.0018236, 0.0019573, 0.0012725]
|
||||
- [0.0023047, 0.002207, 0.001507]
|
||||
- [0.0027443, 0.0024618, 0.0018783]
|
||||
- [0.003144, 0.002839, 0.0023531]
|
||||
- [0.003569, 0.0034246, 0.0028588]
|
||||
- [0.0041098, 0.0042093, 0.0033344]
|
||||
- [0.0048314, 0.0050855, 0.0037723]
|
||||
- [0.0057427, 0.005917, 0.0042245]
|
||||
- [0.0068013, 0.0066361, 0.0047736]
|
||||
- [0.0079474, 0.0073043, 0.0054929]
|
||||
- [0.0091441, 0.0080916, 0.0064221]
|
||||
- [0.0104, 0.0091855, 0.0075673]
|
||||
- [0.011767, 0.010688, 0.0089168]
|
||||
- [0.013317, 0.012568, 0.010453]
|
||||
- [0.015126, 0.014694, 0.012153]
|
||||
- [0.017248, 0.016923, 0.013987]
|
||||
- [0.01971, 0.019183, 0.015926]
|
||||
- [0.022502, 0.021503, 0.017974]
|
||||
- [0.025569, 0.023981, 0.020197]
|
||||
- [0.028823, 0.026731, 0.022722]
|
||||
- [0.032173, 0.02985, 0.025692]
|
||||
- [0.035578, 0.033387, 0.02917]
|
||||
- [0.039136, 0.037371, 0.033152]
|
||||
- [0.043084, 0.041858, 0.03764]
|
||||
- [0.047658, 0.04687, 0.042612]
|
||||
- [0.052905, 0.05228, 0.047942]
|
||||
- [0.05865, 0.057808, 0.053448]
|
||||
- [0.064659, 0.063235, 0.059093]
|
||||
- [0.070845, 0.06866, 0.065123]
|
||||
- [0.077285, 0.074548, 0.071914]
|
||||
- [0.084089, 0.081412, 0.07961]
|
||||
- [0.091409, 0.089492, 0.08801]
|
||||
- [0.099393, 0.098608, 0.096763]
|
||||
- [0.10812, 0.10825, 0.10563]
|
||||
- [0.11757, 0.11792, 0.11466]
|
||||
- [0.12752, 0.12741, 0.12411]
|
||||
- [0.13765, 0.13694, 0.13425]
|
||||
- [0.14764, 0.14687, 0.14521]
|
||||
- [0.15736, 0.15738, 0.15692]
|
||||
- [0.16703, 0.16831, 0.16928]
|
||||
- [0.17714, 0.17927, 0.18233]
|
||||
- [0.18823, 0.19002, 0.19622]
|
||||
- [0.20054, 0.2008, 0.211]
|
||||
- [0.21383, 0.21218, 0.22641]
|
||||
- [0.22746, 0.22474, 0.24197]
|
||||
- [0.24073, 0.23853, 0.25718]
|
||||
- [0.25334, 0.25301, 0.27188]
|
||||
- [0.26551, 0.26736, 0.28645]
|
||||
- [0.27785, 0.28102, 0.30159]
|
||||
- [0.29092, 0.29406, 0.3179]
|
||||
- [0.30487, 0.30704, 0.33551]
|
||||
- [0.31939, 0.32051, 0.35401]
|
||||
- [0.33393, 0.33457, 0.37274]
|
||||
- [0.34812, 0.34876, 0.39116]
|
||||
- [0.36197, 0.36249, 0.40908]
|
||||
- [0.37578, 0.37559, 0.42655]
|
||||
- [0.38977, 0.3885, 0.44375]
|
||||
- [0.40387, 0.40197, 0.46074]
|
||||
- [0.41767, 0.41652, 0.47757]
|
||||
- [0.43084, 0.43191, 0.49435]
|
||||
- [0.44343, 0.44716, 0.5112]
|
||||
- [0.45586, 0.46124, 0.52836]
|
||||
- [0.46866, 0.47387, 0.54604]
|
||||
- [0.48217, 0.48565, 0.56433]
|
||||
- [0.4963, 0.49761, 0.58292]
|
||||
- [0.51061, 0.51051, 0.60113]
|
||||
- [0.52462, 0.52437, 0.61824]
|
||||
- [0.53804, 0.53859, 0.63411]
|
||||
- [0.55088, 0.55245, 0.64925]
|
||||
- [0.56343, 0.56555, 0.6643]
|
||||
- [0.57591, 0.57802, 0.67969]
|
||||
- [0.58834, 0.59037, 0.69545]
|
||||
- [0.60055, 0.60301, 0.71135]
|
||||
- [0.61242, 0.61595, 0.72718]
|
||||
- [0.62415, 0.62882, 0.74297]
|
||||
- [0.63622, 0.64125, 0.75888]
|
||||
- [0.64917, 0.65314, 0.77496]
|
||||
- [0.66318, 0.66483, 0.79101]
|
||||
- [0.67793, 0.67683, 0.8066]
|
||||
- [0.69264, 0.68947, 0.82144]
|
||||
- [0.70653, 0.70271, 0.83561]
|
||||
- [0.71925, 0.71618, 0.84967]
|
||||
- [0.73101, 0.72948, 0.86428]
|
||||
- [0.74251, 0.74241, 0.87981]
|
||||
- [0.75441, 0.75497, 0.89613]
|
||||
- [0.76703, 0.76726, 0.91269]
|
||||
- [0.78036, 0.77946, 0.92879]
|
||||
- [0.79421, 0.79175, 0.94391]
|
||||
- [0.80823, 0.8041, 0.95819]
|
||||
- [0.82198, 0.81621, 0.97251]
|
||||
- [0.83518, 0.82786, 0.9879]
|
||||
- [0.8481, 0.83946, 1.0047]
|
||||
- [0.86139, 0.85194, 1.0221]
|
||||
- [0.87538, 0.86577, 1.039]
|
||||
- [0.88972, 0.88031, 1.0551]
|
||||
- [0.90371, 0.89433, 1.0706]
|
||||
- [0.91684, 0.90691, 1.0864]
|
||||
- [0.92927, 0.91812, 1.1029]
|
||||
- [0.94171, 0.92893, 1.1198]
|
||||
- [0.95494, 0.94059, 1.1364]
|
||||
- [0.96923, 0.95374, 1.152]
|
||||
- [0.98419, 0.968, 1.1668]
|
||||
- [0.99903, 0.98234, 1.1814]
|
||||
- [1.0132, 0.99582, 1.1967]
|
||||
- [1.0266, 1.0082, 1.2129]
|
||||
- [1.0396, 1.0201, 1.2298]
|
||||
- [1.0527, 1.0324, 1.2466]
|
||||
- [1.0659, 1.0455, 1.2628]
|
||||
- [1.0791, 1.0592, 1.2788]
|
||||
- [1.0922, 1.073, 1.2952]
|
||||
- [1.1049, 1.0865, 1.3126]
|
||||
- [1.1174, 1.0994, 1.3306]
|
||||
- [1.1299, 1.1116, 1.3482]
|
||||
- [1.1426, 1.1234, 1.3642]
|
||||
- [1.1558, 1.135, 1.3786]
|
||||
- [1.1691, 1.1467, 1.3922]
|
||||
- [1.1822, 1.1584, 1.4061]
|
||||
- [1.1949, 1.1701, 1.4213]
|
||||
- [1.2073, 1.1819, 1.4379]
|
||||
- [1.2198, 1.1938, 1.4552]
|
||||
- [1.2325, 1.2062, 1.4723]
|
||||
- [1.2453, 1.2187, 1.4887]
|
||||
- [1.2578, 1.2312, 1.5043]
|
||||
- [1.27, 1.2435, 1.5196]
|
||||
- [1.2818, 1.2555, 1.5352]
|
||||
- [1.2935, 1.2673, 1.5511]
|
||||
- [1.3051, 1.2791, 1.567]
|
||||
- [1.3164, 1.2908, 1.5826]
|
||||
- [1.3272, 1.3025, 1.5975]
|
||||
- [1.3375, 1.314, 1.612]
|
||||
- [1.3476, 1.325, 1.6265]
|
||||
- [1.3581, 1.3359, 1.6417]
|
||||
- [1.3692, 1.3467, 1.6577]
|
||||
- [1.3806, 1.3578, 1.6742]
|
||||
- [1.3915, 1.3692, 1.6907]
|
||||
- [1.4014, 1.3806, 1.7067]
|
||||
- [1.4099, 1.3916, 1.722]
|
||||
- [1.4177, 1.4016, 1.7369]
|
||||
- [1.4257, 1.4108, 1.7518]
|
||||
- [1.4344, 1.4194, 1.7666]
|
||||
- [1.4436, 1.4279, 1.7813]
|
||||
- [1.4523, 1.4365, 1.7953]
|
||||
- [1.4599, 1.4452, 1.8086]
|
||||
- [1.4665, 1.454, 1.8213]
|
||||
- [1.4728, 1.4627, 1.8339]
|
||||
- [1.4795, 1.471, 1.8467]
|
||||
- [1.4866, 1.4789, 1.8598]
|
||||
- [1.4936, 1.4862, 1.8731]
|
||||
- [1.4997, 1.493, 1.8861]
|
||||
- [1.5046, 1.4994, 1.8986]
|
||||
- [1.5088, 1.5055, 1.9103]
|
||||
- [1.5131, 1.5118, 1.9213]
|
||||
- [1.5181, 1.5182, 1.932]
|
||||
- [1.5235, 1.5246, 1.9427]
|
||||
- [1.5288, 1.5306, 1.9535]
|
||||
- [1.5331, 1.536, 1.9641]
|
||||
- [1.5361, 1.5407, 1.9742]
|
||||
- [1.5384, 1.545, 1.9835]
|
||||
- [1.5409, 1.5493, 1.992]
|
||||
- [1.5441, 1.5536, 2]
|
||||
- [1.5481, 1.5579, 2.0078]
|
||||
- [1.5521, 1.5619, 2.0157]
|
||||
- [1.5554, 1.5654, 2.0236]
|
||||
- [1.5577, 1.5684, 2.0312]
|
||||
- [1.5591, 1.571, 2.0382]
|
||||
- [1.5604, 1.5737, 2.0444]
|
||||
- [1.5621, 1.5766, 2.0501]
|
||||
- [1.5643, 1.5796, 2.0554]
|
||||
- [1.5666, 1.5824, 2.0606]
|
||||
- [1.5685, 1.5846, 2.0658]
|
||||
- [1.5697, 1.5861, 2.0709]
|
||||
- [1.5704, 1.5871, 2.0756]
|
||||
- [1.5709, 1.588, 2.08]
|
||||
- [1.5715, 1.5892, 2.084]
|
||||
- [1.5723, 1.5908, 2.0877]
|
||||
- [1.5732, 1.5925, 2.0911]
|
||||
- [1.5742, 1.5941, 2.0941]
|
||||
- [1.5752, 1.5955, 2.0967]
|
||||
- [1.5761, 1.5967, 2.099]
|
||||
- [1.5768, 1.5976, 2.1014]
|
||||
- [1.5774, 1.5984, 2.1037]
|
||||
- [1.5778, 1.5992, 2.106]
|
||||
- [1.5782, 1.5999, 2.1079]
|
||||
- [1.5785, 1.6006, 2.1095]
|
||||
- [1.5787, 1.6011, 2.1108]
|
||||
- [1.5788, 1.6015, 2.1119]
|
||||
- [1.579, 1.6016, 2.1129]
|
||||
- [1.5792, 1.6017, 2.1141]
|
||||
- [1.5794, 1.6018, 2.1155]
|
||||
- [1.5798, 1.602, 2.1168]
|
||||
- [1.58, 1.6023, 2.1179]
|
||||
- [1.5802, 1.6026, 2.1189]
|
||||
- [1.5803, 1.6029, 2.1197]
|
||||
- [1.5802, 1.6031, 2.1204]
|
||||
- [1.5801, 1.6033, 2.1209]
|
||||
- [1.5801, 1.6034, 2.1214]
|
||||
- [1.5801, 1.6035, 2.1217]
|
||||
- [1.5803, 1.6037, 2.122]
|
||||
- [1.5805, 1.604, 2.1222]
|
||||
- [1.5806, 1.6042, 2.1226]
|
||||
- [1.5807, 1.6043, 2.123]
|
||||
- [1.5807, 1.6044, 2.1234]
|
||||
- [1.5807, 1.6043, 2.1237]
|
||||
- [1.5806, 1.6043, 2.1238]
|
||||
- [1.5806, 1.6044, 2.1237]
|
||||
- [1.5806, 1.6044, 2.1237]
|
||||
- [1.5807, 1.6044, 2.1237]
|
||||
- [1.5808, 1.6043, 2.124]
|
||||
- [1.581, 1.6042, 2.1243]
|
||||
- [1.5811, 1.6041, 2.1245]
|
||||
- [1.5811, 1.6041, 2.1246]
|
||||
- [1.581, 1.6042, 2.1244]
|
||||
- [1.5807, 1.6044, 2.1243]
|
||||
- [1.5805, 1.6046, 2.1244]
|
||||
- [1.5804, 1.6047, 2.1247]
|
||||
- [1.5804, 1.6047, 2.1251]
|
||||
- [1.5806, 1.6045, 2.1254]
|
||||
- [1.5808, 1.6043, 2.1255]
|
||||
- [1.581, 1.6041, 2.1254]
|
||||
- [1.581, 1.6041, 2.1251]
|
||||
- [1.5809, 1.6042, 2.1249]
|
||||
- [1.5808, 1.6044, 2.1249]
|
||||
- [1.5807, 1.6045, 2.1249]
|
||||
- [1.5806, 1.6047, 2.1248]
|
||||
- [1.5806, 1.6047, 2.1247]
|
||||
- [1.5805, 1.6048, 2.1246]
|
||||
- [1.5805, 1.6048, 2.1246]
|
||||
@@ -1,452 +0,0 @@
|
||||
# Generated by tools/film-profiles/convert.py from spektrafilm.
|
||||
# Do not edit by hand: re-run the converter instead.
|
||||
#
|
||||
# spektrafilm by Andrea Volpato, https://github.com/andreavolpato/spektrafilm
|
||||
# Licensed CC BY-SA 4.0. Modified for DarkRoom: trimmed to the fields the
|
||||
# renderer uses and reformatted; see profiles/CHANGELOG.txt.
|
||||
|
||||
version: '0.3.2'
|
||||
stock: fujifilm_provia_100f
|
||||
name: 'Fujifilm Provia 100F'
|
||||
kind: positive # negative | positive
|
||||
support: film # film | paper
|
||||
stage: filming # filming | printing
|
||||
monochrome: false
|
||||
reference_illuminant: D55
|
||||
viewing_illuminant: D50
|
||||
|
||||
# log10 spectral sensitivity per layer, 380-780nm at 5nm, in R,G,B layer
|
||||
# order. A null upstream means the datasheet has no reading there, which is
|
||||
# blindness, so it is written as the sentinel the loader reads as such.
|
||||
log_sensitivity:
|
||||
- [-3.27854, -3.446, -2.91708] # 380nm
|
||||
- [-3.26416, -3.39997, -2.5622] # 385nm
|
||||
- [-3.24927, -3.35169, -2.17506] # 390nm
|
||||
- [-3.23383, -3.301, -1.77696] # 395nm
|
||||
- [-3.21782, -3.24771, -1.38582] # 400nm
|
||||
- [-3.20121, -3.19162, -1.02441] # 405nm
|
||||
- [-3.18396, -3.13249, -0.690857] # 410nm
|
||||
- [-3.16604, -3.07008, -0.530376] # 415nm
|
||||
- [-3.14739, -3.0041, -0.469205] # 420nm
|
||||
- [-3.12799, -2.93424, -0.435437] # 425nm
|
||||
- [-3.10778, -2.86015, -0.421965] # 430nm
|
||||
- [-3.08671, -2.78142, -0.421164] # 435nm
|
||||
- [-3.06472, -2.69762, -0.411369] # 440nm
|
||||
- [-3.04175, -2.60823, -0.393547] # 445nm
|
||||
- [-3.01774, -2.51267, -0.362401] # 450nm
|
||||
- [-2.99261, -2.41029, -0.314866] # 455nm
|
||||
- [-2.96629, -2.30033, -0.296313] # 460nm
|
||||
- [-2.93868, -2.1819, -0.308723] # 465nm
|
||||
- [-2.90969, -2.05401, -0.369059] # 470nm
|
||||
- [-2.87922, -1.91545, -0.489683] # 475nm
|
||||
- [-2.84714, -1.76485, -0.694112] # 480nm
|
||||
- [-2.81333, -1.61679, -0.97105] # 485nm
|
||||
- [-2.77764, -1.46097, -1.28311] # 490nm
|
||||
- [-2.73991, -1.30768, -1.61248] # 495nm
|
||||
- [-2.69996, -1.1708, -1.9176] # 500nm
|
||||
- [-2.65759, -1.05465, -2.21498] # 505nm
|
||||
- [-2.61257, -0.952251, -2.48758] # 510nm
|
||||
- [-2.56465, -0.855799, -2.73837] # 515nm
|
||||
- [-2.51353, -0.764411, -2.96987] # 520nm
|
||||
- [-2.45888, -0.677428, -3.18422] # 525nm
|
||||
- [-2.40034, -0.601212, -3.38326] # 530nm
|
||||
- [-2.33745, -0.533179, -3.56858] # 535nm
|
||||
- [-2.26973, -0.467866, -3.74154] # 540nm
|
||||
- [-2.19659, -0.421884, -3.90334] # 545nm
|
||||
- [-2.11736, -0.41634, -4.05503] # 550nm
|
||||
- [-2.03124, -0.436924, -4.19752] # 555nm
|
||||
- [-1.93728, -0.464948, -4.33163] # 560nm
|
||||
- [-1.83438, -0.481413, -4.45808] # 565nm
|
||||
- [-1.72119, -0.476751, -4.57751] # 570nm
|
||||
- [-1.59609, -0.453429, -4.69048] # 575nm
|
||||
- [-1.50567, -0.42914, -4.7975] # 580nm
|
||||
- [-1.3884, -0.46206, -4.89903] # 585nm
|
||||
- [-1.21106, -0.678363, -4.99549] # 590nm
|
||||
- [-1.02992, -1.18858, -5.08725] # 595nm
|
||||
- [-0.862347, -1.67342, -5.17463] # 600nm
|
||||
- [-0.749535, -2.11948, -5.25795] # 605nm
|
||||
- [-0.693306, -2.53123, -5.33748] # 610nm
|
||||
- [-0.639163, -2.91248, -5.41348] # 615nm
|
||||
- [-0.573543, -3.26649, -5.48617] # 620nm
|
||||
- [-0.492354, -3.59609, -5.55577] # 625nm
|
||||
- [-0.395313, -3.90372, -5.62247] # 630nm
|
||||
- [-0.2868, -4.1915, -5.68645] # 635nm
|
||||
- [-0.175506, -4.46129, -5.74787] # 640nm
|
||||
- [-0.0880352, -4.71474, -5.80688] # 645nm
|
||||
- [-0.0986827, -4.95327, -5.86362] # 650nm
|
||||
- [-0.201359, -5.17817, -5.91822] # 655nm
|
||||
- [-0.442689, -5.39058, -5.97079] # 660nm
|
||||
- [-0.919104, -5.59151, -6.02146] # 665nm
|
||||
- [-1.49049, -5.78186, -6.07031] # 670nm
|
||||
- [-2.02926, -5.96245, -6.11745] # 675nm
|
||||
- [-2.51671, -6.13401, -6.16297] # 680nm
|
||||
- [-2.95985, -6.29721, -6.20694] # 685nm
|
||||
- [-3.36446, -6.45263, -6.24945] # 690nm
|
||||
- [-3.73535, -6.60082, -6.29056] # 695nm
|
||||
- [-4.07657, -6.74228, -6.33035] # 700nm
|
||||
- [-4.39154, -6.87745, -6.36887] # 705nm
|
||||
- [-4.68318, -7.00674, -6.40619] # 710nm
|
||||
- [-4.95399, -7.13053, -6.44237] # 715nm
|
||||
- [-5.20612, -7.24916, -6.47744] # 720nm
|
||||
- [-5.44144, -7.36295, -6.51147] # 725nm
|
||||
- [-5.66159, -7.47219, -6.5445] # 730nm
|
||||
- [-5.86797, -7.57715, -6.57657] # 735nm
|
||||
- [-6.06184, -7.67807, -6.60772] # 740nm
|
||||
- [-6.24431, -7.77518, -6.638] # 745nm
|
||||
- [-6.41636, -7.86869, -6.66743] # 750nm
|
||||
- [-6.57884, -7.9588, -6.69606] # 755nm
|
||||
- [-6.73254, -8.0457, -6.72392] # 760nm
|
||||
- [-6.87815, -8.12954, -6.75103] # 765nm
|
||||
- [-7.0163, -8.2105, -6.77743] # 770nm
|
||||
- [-7.14754, -8.28871, -6.80314] # 775nm
|
||||
- [-7.27237, -8.36431, -6.8282] # 780nm
|
||||
|
||||
# Spectral density of each layer's dye at unit density, same grid and order.
|
||||
dye_density:
|
||||
- [0, 0, 0] # 380nm
|
||||
- [0.340585, 0.167429, 0.450022] # 385nm
|
||||
- [0.317222, 0.171975, 0.511085] # 390nm
|
||||
- [0.294334, 0.176237, 0.584739] # 395nm
|
||||
- [0.277553, 0.182409, 0.665767] # 400nm
|
||||
- [0.262595, 0.188487, 0.749822] # 405nm
|
||||
- [0.249865, 0.197039, 0.842488] # 410nm
|
||||
- [0.237666, 0.206535, 0.934394] # 415nm
|
||||
- [0.226554, 0.215115, 1.0118] # 420nm
|
||||
- [0.215966, 0.225818, 1.07136] # 425nm
|
||||
- [0.205587, 0.232808, 1.11735] # 430nm
|
||||
- [0.195694, 0.232378, 1.15061] # 435nm
|
||||
- [0.186408, 0.228196, 1.162] # 440nm
|
||||
- [0.176765, 0.221838, 1.15394] # 445nm
|
||||
- [0.167797, 0.220735, 1.12812] # 450nm
|
||||
- [0.160118, 0.227428, 1.08562] # 455nm
|
||||
- [0.152837, 0.239972, 1.02469] # 460nm
|
||||
- [0.145544, 0.260758, 0.944332] # 465nm
|
||||
- [0.139133, 0.292342, 0.857485] # 470nm
|
||||
- [0.133802, 0.332155, 0.770171] # 475nm
|
||||
- [0.128561, 0.380859, 0.673461] # 480nm
|
||||
- [0.125183, 0.437982, 0.585594] # 485nm
|
||||
- [0.123132, 0.502186, 0.502111] # 490nm
|
||||
- [0.123177, 0.584157, 0.421624] # 495nm
|
||||
- [0.125027, 0.665414, 0.355637] # 500nm
|
||||
- [0.128379, 0.739195, 0.289161] # 505nm
|
||||
- [0.133974, 0.824938, 0.233355] # 510nm
|
||||
- [0.141885, 0.908946, 0.186757] # 515nm
|
||||
- [0.152957, 0.982131, 0.150276] # 520nm
|
||||
- [0.165908, 1.03467, 0.116434] # 525nm
|
||||
- [0.184358, 1.06615, 0.0927855] # 530nm
|
||||
- [0.20924, 1.08375, 0.0755337] # 535nm
|
||||
- [0.235768, 1.07989, 0.0634362] # 540nm
|
||||
- [0.263741, 1.064, 0.0548441] # 545nm
|
||||
- [0.294088, 1.03436, 0.048549] # 550nm
|
||||
- [0.326789, 0.990486, 0.0442157] # 555nm
|
||||
- [0.363118, 0.929966, 0.0409986] # 560nm
|
||||
- [0.402213, 0.859209, 0.0382997] # 565nm
|
||||
- [0.444833, 0.785694, 0.0364875] # 570nm
|
||||
- [0.490344, 0.710945, 0.0347009] # 575nm
|
||||
- [0.537961, 0.626937, 0.0333383] # 580nm
|
||||
- [0.5883, 0.554466, 0.0326828] # 585nm
|
||||
- [0.643578, 0.482206, 0.0325632] # 590nm
|
||||
- [0.70262, 0.421966, 0.0323535] # 595nm
|
||||
- [0.762281, 0.35931, 0.0317377] # 600nm
|
||||
- [0.823007, 0.309663, 0.0328651] # 605nm
|
||||
- [0.882433, 0.263622, 0.0309579] # 610nm
|
||||
- [0.940826, 0.225974, 0.0306508] # 615nm
|
||||
- [0.993406, 0.195083, 0.0305293] # 620nm
|
||||
- [1.04038, 0.169247, 0.0308059] # 625nm
|
||||
- [1.07942, 0.147295, 0.031742] # 630nm
|
||||
- [1.11165, 0.12886, 0.0323982] # 635nm
|
||||
- [1.13828, 0.114916, 0.0337083] # 640nm
|
||||
- [1.15907, 0.101852, 0.0348531] # 645nm
|
||||
- [1.16928, 0.0902782, 0.0350249] # 650nm
|
||||
- [1.16785, 0.0797517, 0.0337312] # 655nm
|
||||
- [1.1564, 0.0699903, 0.0316397] # 660nm
|
||||
- [1.13796, 0.0608943, 0.0284302] # 665nm
|
||||
- [1.11138, 0.0536555, 0.0253923] # 670nm
|
||||
- [1.07802, 0.0470709, 0.0213704] # 675nm
|
||||
- [1.04001, 0.0417618, 0.0175123] # 680nm
|
||||
- [1.00111, 0.036918, 0.0149526] # 685nm
|
||||
- [0.953646, 0.0329179, 0.0131364] # 690nm
|
||||
- [0.90109, 0.0302608, 0.0114814] # 695nm
|
||||
- [0.846141, 0.0275454, 0.0100454] # 700nm
|
||||
- [0.789601, 0.0250569, 0.00960445] # 705nm
|
||||
- [0.733837, 0.0237014, 0.00960445] # 710nm
|
||||
- [0.680331, 0.0236079, 0.00960445] # 715nm
|
||||
- [0, 0, 0] # 720nm
|
||||
- [0, 0, 0] # 725nm
|
||||
- [0, 0, 0] # 730nm
|
||||
- [0, 0, 0] # 735nm
|
||||
- [0, 0, 0] # 740nm
|
||||
- [0, 0, 0] # 745nm
|
||||
- [0, 0, 0] # 750nm
|
||||
- [0, 0, 0] # 755nm
|
||||
- [0, 0, 0] # 760nm
|
||||
- [0, 0, 0] # 765nm
|
||||
- [0, 0, 0] # 770nm
|
||||
- [0, 0, 0] # 775nm
|
||||
- [0, 0, 0] # 780nm
|
||||
|
||||
# The support's own density -- film base plus, for a colour negative, the
|
||||
# orange mask. Flat zero where the datasheet does not give it.
|
||||
base_density: [0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900273, 0.0900274, 0.0900274, 0.0900274, 0.0900276, 0.0900279, 0.0900284, 0.0900293, 0.090031, 0.0900337, 0.0900378, 0.0900439, 0.0900524, 0.0900638, 0.0900782, 0.0900958, 0.0901165, 0.09014, 0.090166, 0.0901938, 0.090223, 0.0902531, 0.0902836, 0.0903142, 0.0903443, 0.0903735, 0.0904013, 0.0904272, 0.0904508, 0.0904715, 0.0904891, 0.0905035, 0.0905148, 0.0905233, 0.0905294, 0.0905336, 0.0905363, 0.0905379, 0.0905389, 0.0905394, 0.0905397, 0.0905398, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399, 0.0905399]
|
||||
|
||||
# The characteristic curves: density against log10 exposure, sampled
|
||||
# uniformly over [-3, 4].
|
||||
log_exposure_min: -3
|
||||
log_exposure_max: 4
|
||||
density_curves:
|
||||
- [2.5543, 2.5433, 2.0745]
|
||||
- [2.5544, 2.5434, 2.0745]
|
||||
- [2.5546, 2.5436, 2.0747]
|
||||
- [2.5548, 2.5439, 2.0749]
|
||||
- [2.555, 2.544, 2.0751]
|
||||
- [2.5551, 2.5441, 2.0754]
|
||||
- [2.5552, 2.544, 2.0756]
|
||||
- [2.5554, 2.5441, 2.0757]
|
||||
- [2.5555, 2.5442, 2.0756]
|
||||
- [2.5556, 2.5444, 2.0755]
|
||||
- [2.5557, 2.5445, 2.0753]
|
||||
- [2.5556, 2.5444, 2.0752]
|
||||
- [2.5554, 2.5442, 2.0752]
|
||||
- [2.5552, 2.544, 2.0752]
|
||||
- [2.5551, 2.5439, 2.0753]
|
||||
- [2.5549, 2.5439, 2.0753]
|
||||
- [2.5549, 2.544, 2.0754]
|
||||
- [2.5549, 2.5441, 2.0755]
|
||||
- [2.5549, 2.5442, 2.0755]
|
||||
- [2.555, 2.5443, 2.0753]
|
||||
- [2.5551, 2.5443, 2.0751]
|
||||
- [2.5552, 2.5442, 2.0748]
|
||||
- [2.5551, 2.544, 2.0746]
|
||||
- [2.5549, 2.5438, 2.0745]
|
||||
- [2.5544, 2.5436, 2.0745]
|
||||
- [2.554, 2.5435, 2.0745]
|
||||
- [2.5535, 2.5435, 2.0744]
|
||||
- [2.5532, 2.5436, 2.0741]
|
||||
- [2.5529, 2.5437, 2.0737]
|
||||
- [2.5527, 2.5435, 2.0732]
|
||||
- [2.5523, 2.5432, 2.0727]
|
||||
- [2.5518, 2.5428, 2.0723]
|
||||
- [2.5509, 2.5424, 2.0718]
|
||||
- [2.5499, 2.5421, 2.0711]
|
||||
- [2.5488, 2.5419, 2.0702]
|
||||
- [2.5476, 2.5416, 2.069]
|
||||
- [2.5463, 2.5409, 2.0675]
|
||||
- [2.5448, 2.5398, 2.0658]
|
||||
- [2.543, 2.5384, 2.0639]
|
||||
- [2.5408, 2.5367, 2.0619]
|
||||
- [2.5381, 2.535, 2.0597]
|
||||
- [2.535, 2.5333, 2.0572]
|
||||
- [2.5314, 2.5315, 2.0543]
|
||||
- [2.5274, 2.5294, 2.051]
|
||||
- [2.5231, 2.5267, 2.0471]
|
||||
- [2.5182, 2.5234, 2.0425]
|
||||
- [2.5129, 2.5192, 2.0373]
|
||||
- [2.5068, 2.5143, 2.0312]
|
||||
- [2.4999, 2.5088, 2.0246]
|
||||
- [2.4918, 2.5025, 2.0174]
|
||||
- [2.4827, 2.4954, 2.0096]
|
||||
- [2.4723, 2.4876, 2.0012]
|
||||
- [2.4609, 2.4789, 1.9921]
|
||||
- [2.4488, 2.4692, 1.9822]
|
||||
- [2.4359, 2.4584, 1.9713]
|
||||
- [2.4219, 2.4459, 1.9589]
|
||||
- [2.4065, 2.4314, 1.9451]
|
||||
- [2.3895, 2.4149, 1.93]
|
||||
- [2.371, 2.3968, 1.9142]
|
||||
- [2.3509, 2.3777, 1.8977]
|
||||
- [2.3296, 2.3578, 1.8802]
|
||||
- [2.307, 2.3369, 1.8613]
|
||||
- [2.2833, 2.3143, 1.8405]
|
||||
- [2.2585, 2.2893, 1.8181]
|
||||
- [2.2324, 2.2617, 1.7942]
|
||||
- [2.2044, 2.2316, 1.7693]
|
||||
- [2.1743, 2.1996, 1.7436]
|
||||
- [2.1419, 2.1659, 1.7171]
|
||||
- [2.1076, 2.1305, 1.6896]
|
||||
- [2.072, 2.0928, 1.6613]
|
||||
- [2.0359, 2.0526, 1.6324]
|
||||
- [1.9998, 2.0104, 1.6032]
|
||||
- [1.9634, 1.9671, 1.5733]
|
||||
- [1.9261, 1.9237, 1.5422]
|
||||
- [1.8871, 1.8805, 1.5095]
|
||||
- [1.8461, 1.8369, 1.4751]
|
||||
- [1.8035, 1.792, 1.4396]
|
||||
- [1.7603, 1.7451, 1.404]
|
||||
- [1.7173, 1.6967, 1.3693]
|
||||
- [1.675, 1.6477, 1.3357]
|
||||
- [1.633, 1.5991, 1.3029]
|
||||
- [1.5908, 1.5512, 1.27]
|
||||
- [1.5481, 1.5037, 1.2367]
|
||||
- [1.5049, 1.4558, 1.2028]
|
||||
- [1.4619, 1.4077, 1.1685]
|
||||
- [1.4194, 1.3599, 1.1341]
|
||||
- [1.3774, 1.3138, 1.0998]
|
||||
- [1.3355, 1.27, 1.0658]
|
||||
- [1.2933, 1.2284, 1.0323]
|
||||
- [1.251, 1.1876, 0.99939]
|
||||
- [1.2092, 1.1465, 0.96746]
|
||||
- [1.1686, 1.1047, 0.93677]
|
||||
- [1.1296, 1.0631, 0.90742]
|
||||
- [1.092, 1.023, 0.87905]
|
||||
- [1.0554, 0.98521, 0.85088]
|
||||
- [1.0191, 0.94969, 0.82211]
|
||||
- [0.98269, 0.91565, 0.79256]
|
||||
- [0.94627, 0.88213, 0.76277]
|
||||
- [0.91009, 0.84862, 0.73345]
|
||||
- [0.87441, 0.81522, 0.70503]
|
||||
- [0.8392, 0.78244, 0.67749]
|
||||
- [0.80423, 0.75066, 0.65052]
|
||||
- [0.76933, 0.71981, 0.62388]
|
||||
- [0.73469, 0.68941, 0.59756]
|
||||
- [0.70085, 0.65897, 0.5717]
|
||||
- [0.66837, 0.62838, 0.54634]
|
||||
- [0.63744, 0.59796, 0.52123]
|
||||
- [0.60764, 0.56824, 0.49593]
|
||||
- [0.57815, 0.53956, 0.47011]
|
||||
- [0.54814, 0.51185, 0.44392]
|
||||
- [0.51729, 0.48473, 0.41796]
|
||||
- [0.48596, 0.45781, 0.39296]
|
||||
- [0.45492, 0.43094, 0.36932]
|
||||
- [0.42491, 0.40417, 0.34691]
|
||||
- [0.3963, 0.37768, 0.32518]
|
||||
- [0.3691, 0.35171, 0.3035]
|
||||
- [0.34315, 0.32652, 0.28147]
|
||||
- [0.31816, 0.30213, 0.25933]
|
||||
- [0.29379, 0.27833, 0.23798]
|
||||
- [0.2699, 0.25502, 0.21837]
|
||||
- [0.24679, 0.23265, 0.20073]
|
||||
- [0.22507, 0.21203, 0.18439]
|
||||
- [0.205, 0.19354, 0.16848]
|
||||
- [0.18631, 0.17661, 0.15272]
|
||||
- [0.16846, 0.16028, 0.13751]
|
||||
- [0.15116, 0.14394, 0.12343]
|
||||
- [0.13456, 0.12773, 0.11077]
|
||||
- [0.11919, 0.11244, 0.099287]
|
||||
- [0.10551, 0.098883, 0.088469]
|
||||
- [0.093613, 0.087392, 0.077965]
|
||||
- [0.083149, 0.077608, 0.067863]
|
||||
- [0.073605, 0.068802, 0.05859]
|
||||
- [0.06463, 0.06039, 0.050554]
|
||||
- [0.056196, 0.052262, 0.043834]
|
||||
- [0.048481, 0.044737, 0.038138]
|
||||
- [0.041628, 0.03822, 0.033039]
|
||||
- [0.035638, 0.032854, 0.028288]
|
||||
- [0.030392, 0.028458, 0.023994]
|
||||
- [0.025735, 0.024723, 0.020457]
|
||||
- [0.02156, 0.021383, 0.017806]
|
||||
- [0.017838, 0.018277, 0.015799]
|
||||
- [0.01462, 0.015347, 0.013946]
|
||||
- [0.011973, 0.012634, 0.01185]
|
||||
- [0.009892, 0.010222, 0.0094557]
|
||||
- [0.0082299, 0.0081576, 0.0070528]
|
||||
- [0.0067829, 0.0064155, 0.0050541]
|
||||
- [0.0054591, 0.0049394, 0.0037427]
|
||||
- [0.0042994, 0.0037164, 0.0031109]
|
||||
- [0.0033912, 0.0027743, 0.0028868]
|
||||
- [0.0027591, 0.0021264, 0.0027192]
|
||||
- [0.0023267, 0.0017209, 0.0023864]
|
||||
- [0.0019742, 0.001446, 0.0018902]
|
||||
- [0.0016295, 0.001192, 0.0013898]
|
||||
- [0.0013057, 0.00091573, 0.0010425]
|
||||
- [0.0010523, 0.00065282, 0.0008748]
|
||||
- [0.00087169, 0.00046612, 0.00077072]
|
||||
- [0.00068895, 0.00037567, 0.00057165]
|
||||
- [0.00041464, 0.0003314, 0.00020385]
|
||||
- [5.0336e-05, 0.00025343, -0.00026182]
|
||||
- [-0.00026665, 0.00010573, -0.00065464]
|
||||
- [-0.00034113, -6.1651e-05, -0.00081371]
|
||||
- [-8.2885e-05, -0.00015295, -0.00067318]
|
||||
- [0.00041581, -6.1147e-05, -0.00031371]
|
||||
- [0.00088712, 0.00025272, 7.9571e-05]
|
||||
- [0.0010138, 0.00067703, 0.00034414]
|
||||
- [0.00068069, 0.0010003, 0.00043541]
|
||||
- [0.00011856, 0.0010456, 0.00043653]
|
||||
- [-0.00024591, 0.0007855, 0.00046909]
|
||||
- [-0.00014417, 0.00035475, 0.00057314]
|
||||
- [0.00029777, -4.2573e-05, 0.00066334]
|
||||
- [0.00067725, -0.00025913, 0.0006087]
|
||||
- [0.00068216, -0.00028102, 0.00036311]
|
||||
- [0.0003325, -0.00016561, -7.2703e-06]
|
||||
- [-5.1127e-05, 9.8278e-06, -0.00035979]
|
||||
- [-0.00012046, 0.00016945, -0.00055013]
|
||||
- [0.00019902, 0.00024227, -0.00051293]
|
||||
- [0.00062919, 0.00017211, -0.0002996]
|
||||
- [0.00075686, -5.1426e-05, -4.9664e-05]
|
||||
- [0.00038003, -0.0003606, 8.9406e-05]
|
||||
- [-0.00029965, -0.00061664, 4.8035e-05]
|
||||
- [-0.00080729, -0.00067606, -0.00012514]
|
||||
- [-0.00076121, -0.00048539, -0.00029495]
|
||||
- [-0.00019163, -0.00013524, -0.0003268]
|
||||
- [0.00044951, 0.00017966, -0.00017655]
|
||||
- [0.00061405, 0.00028237, 7.5227e-05]
|
||||
- [8.0176e-05, 0.00013693, 0.00027055]
|
||||
- [-0.00085311, -0.00012077, 0.00027641]
|
||||
- [-0.0015595, -0.00027699, 7.6167e-05]
|
||||
- [-0.0015328, -0.00019786, -0.00021269]
|
||||
- [-0.00075642, 6.5676e-05, -0.00041069]
|
||||
- [0.00028032, 0.00030979, -0.00039293]
|
||||
- [0.00093224, 0.00033099, -0.00017231]
|
||||
- [0.00085249, 8.9965e-05, 0.0001084]
|
||||
- [0.00021757, -0.0002401, 0.00027097]
|
||||
- [-0.00043186, -0.00038631, 0.00021842]
|
||||
- [-0.00059981, -0.00017661, -5.3125e-06]
|
||||
- [-0.00018225, 0.0003153, -0.00025112]
|
||||
- [0.00049481, 0.00079004, -0.00036657]
|
||||
- [0.000945, 0.00090219, -0.00028892]
|
||||
- [0.00086621, 0.00048513, -6.6985e-05]
|
||||
- [0.00036906, -0.00028899, 0.00017012]
|
||||
- [-0.00021405, -0.0010194, 0.00033786]
|
||||
- [-0.00064117, -0.0013644, 0.00045045]
|
||||
- [-0.00084149, -0.0012406, 0.00054246]
|
||||
- [-0.00080636, -0.00080673, 0.00057742]
|
||||
- [-0.00054163, -0.00030449, 0.00047883]
|
||||
- [-0.00014085, 9.0816e-05, 0.00025062]
|
||||
- [0.00020645, 0.00032748, 2.2587e-05]
|
||||
- [0.00039606, 0.00042477, -7.1076e-05]
|
||||
- [0.00046421, 0.00045154, -3.5319e-06]
|
||||
- [0.00045927, 0.00050805, 0.00014307]
|
||||
- [0.00041221, 0.00062938, 0.00021518]
|
||||
- [0.00032428, 0.00075855, 9.2604e-05]
|
||||
- [0.00019112, 0.00078669, -0.0002217]
|
||||
- [3.4831e-05, 0.00063167, -0.00059374]
|
||||
- [-8.8526e-05, 0.00030183, -0.00084102]
|
||||
- [-0.00011636, -9.7907e-05, -0.00084713]
|
||||
- [-2.3143e-05, -0.00042194, -0.00062749]
|
||||
- [0.00015297, -0.00056606, -0.00030374]
|
||||
- [0.00032266, -0.0005232, -1.3704e-05]
|
||||
- [0.00039221, -0.00037849, 0.00017119]
|
||||
- [0.00031597, -0.00024908, 0.00026249]
|
||||
- [0.00012212, -0.00020961, 0.00030531]
|
||||
- [-0.00010306, -0.00025131, 0.00031688]
|
||||
- [-0.00026154, -0.000299, 0.00027163]
|
||||
- [-0.00029259, -0.00027061, 0.00014416]
|
||||
- [-0.000198, -0.00013689, -3.495e-05]
|
||||
- [-3.3834e-05, 5.8756e-05, -0.00017183]
|
||||
- [0.00012171, 0.00023137, -0.00017006]
|
||||
- [0.00020459, 0.00031205, -1.5488e-05]
|
||||
- [0.00018864, 0.00029076, 0.00018992]
|
||||
- [9.0619e-05, 0.00021548, 0.00028646]
|
||||
- [-3.9992e-05, 0.00015062, 0.00017361]
|
||||
- [-0.00013957, 0.00012675, -0.00010314]
|
||||
- [-0.00015232, 0.00011888, -0.00037067]
|
||||
- [-5.2034e-05, 6.9298e-05, -0.00044858]
|
||||
- [0.00014165, -6.1556e-05, -0.00028559]
|
||||
- [0.00036103, -0.00025352, -5.6164e-06]
|
||||
- [0.00050974, -0.00042611, 0.00017901]
|
||||
- [0.0005025, -0.00048282, 0.00012788]
|
||||
- [0.00031031, -0.00037305, -0.0001142]
|
||||
- [-1.2991e-05, -0.00013156, -0.00034072]
|
||||
- [-0.00034329, 0.00013132, -0.00033198]
|
||||
- [-0.00053914, 0.00028491, -1.9892e-05]
|
||||
- [-0.00051235, 0.00024706, 0.00045446]
|
||||
- [-0.00027822, 2.7433e-05, 0.00083498]
|
||||
- [4.533e-05, -0.00027584, 0.0009199]
|
||||
- [0.00028471, -0.0005081, 0.00068138]
|
||||
- [0.00031968, -0.0005594, 0.00034871]
|
||||
- [0.00017724, -0.00042893, 0.00014744]
|
||||
- [-1.6195e-05, -0.00019955, 9.3372e-05]
|
||||
- [-0.00015589, 3.0964e-05, 5.2417e-05]
|
||||
- [-0.00023099, 0.00020411, -6.4328e-05]
|
||||
- [-0.00028939, 0.00031064, -0.00021919]
|
||||
- [-0.00035502, 0.00036863, -0.00033587]
|
||||
- [-0.00039868, 0.00039829, -0.00039436]
|
||||
@@ -1,452 +0,0 @@
|
||||
# Generated by tools/film-profiles/convert.py from spektrafilm.
|
||||
# Do not edit by hand: re-run the converter instead.
|
||||
#
|
||||
# spektrafilm by Andrea Volpato, https://github.com/andreavolpato/spektrafilm
|
||||
# Licensed CC BY-SA 4.0. Modified for DarkRoom: trimmed to the fields the
|
||||
# renderer uses and reformatted; see profiles/CHANGELOG.txt.
|
||||
|
||||
version: '0.3.2'
|
||||
stock: fujifilm_velvia_100
|
||||
name: 'Fujifilm Velvia 100'
|
||||
kind: positive # negative | positive
|
||||
support: film # film | paper
|
||||
stage: filming # filming | printing
|
||||
monochrome: false
|
||||
reference_illuminant: D55
|
||||
viewing_illuminant: D50
|
||||
|
||||
# log10 spectral sensitivity per layer, 380-780nm at 5nm, in R,G,B layer
|
||||
# order. A null upstream means the datasheet has no reading there, which is
|
||||
# blindness, so it is written as the sentinel the loader reads as such.
|
||||
log_sensitivity:
|
||||
- [-3.46067, -4.1332, -3.16912] # 380nm
|
||||
- [-3.44044, -4.07777, -2.80303] # 385nm
|
||||
- [-3.41952, -4.01971, -2.40878] # 390nm
|
||||
- [-3.39788, -3.95881, -1.983] # 395nm
|
||||
- [-3.37548, -3.89487, -1.52173] # 400nm
|
||||
- [-3.35228, -3.82765, -1.04717] # 405nm
|
||||
- [-3.32825, -3.75689, -0.689314] # 410nm
|
||||
- [-3.30332, -3.6823, -0.517479] # 415nm
|
||||
- [-3.27745, -3.60358, -0.455525] # 420nm
|
||||
- [-3.25059, -3.52035, -0.417597] # 425nm
|
||||
- [-3.22267, -3.43223, -0.401658] # 430nm
|
||||
- [-3.19365, -3.33876, -0.375228] # 435nm
|
||||
- [-3.16343, -3.23946, -0.323421] # 440nm
|
||||
- [-3.13197, -3.13375, -0.283274] # 445nm
|
||||
- [-3.09916, -3.02099, -0.310132] # 450nm
|
||||
- [-3.06494, -2.90046, -0.38891] # 455nm
|
||||
- [-3.02919, -2.77131, -0.474094] # 460nm
|
||||
- [-2.99183, -2.6326, -0.500674] # 465nm
|
||||
- [-2.95273, -2.48322, -0.521555] # 470nm
|
||||
- [-2.91178, -2.32189, -0.603407] # 475nm
|
||||
- [-2.86884, -2.14711, -0.734161] # 480nm
|
||||
- [-2.82376, -1.95714, -0.877703] # 485nm
|
||||
- [-2.77639, -1.7623, -0.994107] # 490nm
|
||||
- [-2.72653, -1.57157, -1.06623] # 495nm
|
||||
- [-2.674, -1.38028, -1.14992] # 500nm
|
||||
- [-2.61858, -1.20902, -1.30529] # 505nm
|
||||
- [-2.56002, -1.04042, -1.52606] # 510nm
|
||||
- [-2.49807, -0.933224, -1.74645] # 515nm
|
||||
- [-2.43243, -0.814152, -1.96609] # 520nm
|
||||
- [-2.36279, -0.673924, -2.17492] # 525nm
|
||||
- [-2.28881, -0.556623, -2.36769] # 530nm
|
||||
- [-2.21016, -0.469029, -2.54617] # 535nm
|
||||
- [-2.12654, -0.408977, -2.71191] # 540nm
|
||||
- [-2.03791, -0.354302, -2.86622] # 545nm
|
||||
- [-1.94519, -0.316193, -3.01023] # 550nm
|
||||
- [-1.85567, -0.330569, -3.14496] # 555nm
|
||||
- [-1.77887, -0.405287, -3.27127] # 560nm
|
||||
- [-1.70567, -0.445626, -3.38992] # 565nm
|
||||
- [-1.64962, -0.453081, -3.5016] # 570nm
|
||||
- [-1.52834, -0.457653, -3.60689] # 575nm
|
||||
- [-1.37743, -0.510379, -3.70633] # 580nm
|
||||
- [-1.21685, -0.615097, -3.8004] # 585nm
|
||||
- [-1.07095, -0.922013, -3.88951] # 590nm
|
||||
- [-0.95086, -1.34105, -3.97406] # 595nm
|
||||
- [-0.858274, -1.82082, -4.05438] # 600nm
|
||||
- [-0.769746, -2.27677, -4.13078] # 605nm
|
||||
- [-0.680491, -2.69624, -4.20354] # 610nm
|
||||
- [-0.59083, -3.08345, -4.27292] # 615nm
|
||||
- [-0.501804, -3.44198, -4.33914] # 620nm
|
||||
- [-0.414956, -3.77489, -4.40242] # 625nm
|
||||
- [-0.329959, -4.08485, -4.46295] # 630nm
|
||||
- [-0.246551, -4.37414, -4.52091] # 635nm
|
||||
- [-0.188507, -4.64477, -4.57645] # 640nm
|
||||
- [-0.180264, -4.89849, -4.62972] # 645nm
|
||||
- [-0.264482, -5.13682, -4.68086] # 650nm
|
||||
- [-0.404872, -5.36114, -4.73] # 655nm
|
||||
- [-0.556563, -5.57264, -4.77724] # 660nm
|
||||
- [-0.703482, -5.77239, -4.82271] # 665nm
|
||||
- [-0.840754, -5.96134, -4.86649] # 670nm
|
||||
- [-1.00663, -6.14035, -4.90867] # 675nm
|
||||
- [-1.25532, -6.31018, -4.94935] # 680nm
|
||||
- [-1.57868, -6.47151, -4.98861] # 685nm
|
||||
- [-1.88779, -6.62498, -5.02651] # 690nm
|
||||
- [-2.17482, -6.77114, -5.06312] # 695nm
|
||||
- [-2.44206, -6.9105, -5.09852] # 700nm
|
||||
- [-2.69148, -7.04353, -5.13275] # 705nm
|
||||
- [-2.92481, -7.17064, -5.16588] # 710nm
|
||||
- [-3.14355, -7.29223, -5.19796] # 715nm
|
||||
- [-3.34904, -7.40864, -5.22903] # 720nm
|
||||
- [-3.54244, -7.5202, -5.25915] # 725nm
|
||||
- [-3.72479, -7.62721, -5.28836] # 730nm
|
||||
- [-3.89701, -7.72994, -5.3167] # 735nm
|
||||
- [-4.05992, -7.82864, -5.3442] # 740nm
|
||||
- [-4.21426, -7.92354, -5.3709] # 745nm
|
||||
- [-4.36068, -8.01486, -5.39684] # 750nm
|
||||
- [-4.49978, -8.1028, -5.42205] # 755nm
|
||||
- [-4.63209, -8.18755, -5.44656] # 760nm
|
||||
- [-4.75811, -8.26926, -5.4704] # 765nm
|
||||
- [-4.87826, -8.34811, -5.4936] # 770nm
|
||||
- [-4.99295, -8.42424, -5.51617] # 775nm
|
||||
- [-5.10254, -8.49779, -5.53816] # 780nm
|
||||
|
||||
# Spectral density of each layer's dye at unit density, same grid and order.
|
||||
dye_density:
|
||||
- [0, 0, 0] # 380nm
|
||||
- [0, 0, 0] # 385nm
|
||||
- [0, 0, 0] # 390nm
|
||||
- [0, 0, 0] # 395nm
|
||||
- [0.103508, 0.0439042, 0.736087] # 400nm
|
||||
- [0.0955073, 0.0389726, 0.793055] # 405nm
|
||||
- [0.0877816, 0.0349954, 0.851657] # 410nm
|
||||
- [0.0801834, 0.0321858, 0.911481] # 415nm
|
||||
- [0.0729001, 0.0308953, 0.969509] # 420nm
|
||||
- [0.0657606, 0.0310694, 1.02438] # 425nm
|
||||
- [0.0588639, 0.0332375, 1.07429] # 430nm
|
||||
- [0.0528495, 0.0368916, 1.12164] # 435nm
|
||||
- [0.0460547, 0.0436813, 1.15294] # 440nm
|
||||
- [0.0390092, 0.0525085, 1.16706] # 445nm
|
||||
- [0.0341579, 0.0631992, 1.15727] # 450nm
|
||||
- [0.0296911, 0.0766163, 1.11987] # 455nm
|
||||
- [0.0273577, 0.0957769, 1.06279] # 460nm
|
||||
- [0.0265341, 0.12149, 0.986092] # 465nm
|
||||
- [0.0270454, 0.153396, 0.889785] # 470nm
|
||||
- [0.028063, 0.1884, 0.783029] # 475nm
|
||||
- [0.0295912, 0.232004, 0.667141] # 480nm
|
||||
- [0.0317152, 0.285046, 0.548391] # 485nm
|
||||
- [0.0342649, 0.34591, 0.437923] # 490nm
|
||||
- [0.0374594, 0.421242, 0.358549] # 495nm
|
||||
- [0.0411935, 0.507529, 0.279448] # 500nm
|
||||
- [0.0455533, 0.59826, 0.214026] # 505nm
|
||||
- [0.0505787, 0.69018, 0.161055] # 510nm
|
||||
- [0.0566154, 0.78006, 0.127514] # 515nm
|
||||
- [0.0634245, 0.864955, 0.0972487] # 520nm
|
||||
- [0.0721136, 0.944086, 0.0733985] # 525nm
|
||||
- [0.0828877, 1.01529, 0.0538613] # 530nm
|
||||
- [0.0954968, 1.07967, 0.0395563] # 535nm
|
||||
- [0.114129, 1.13381, 0.0281628] # 540nm
|
||||
- [0.137576, 1.18378, 0.0194472] # 545nm
|
||||
- [0.16388, 1.21831, 0.012891] # 550nm
|
||||
- [0.193168, 1.22378, 0.0090571] # 555nm
|
||||
- [0.226205, 1.19374, 0.00642384] # 560nm
|
||||
- [0.263176, 1.13251, 0.00528374] # 565nm
|
||||
- [0.305252, 1.04228, 0.00526008] # 570nm
|
||||
- [0.353637, 0.921528, 0.00513917] # 575nm
|
||||
- [0.410895, 0.78927, 0.0043747] # 580nm
|
||||
- [0.476269, 0.659263, 0.0043747] # 585nm
|
||||
- [0.545727, 0.531554, 0.0043747] # 590nm
|
||||
- [0.622993, 0.425497, 0.0043747] # 595nm
|
||||
- [0.701631, 0.33692, 0.00413956] # 600nm
|
||||
- [0.778665, 0.261743, 0.00446621] # 605nm
|
||||
- [0.855515, 0.202167, 0.00341477] # 610nm
|
||||
- [0.924407, 0.16563, 0.00261948] # 615nm
|
||||
- [0.987837, 0.133438, 0.00260396] # 620nm
|
||||
- [1.04612, 0.107518, 0.00260396] # 625nm
|
||||
- [1.09843, 0.0858923, 0.00260396] # 630nm
|
||||
- [1.14513, 0.0677051, 0.00260396] # 635nm
|
||||
- [1.18644, 0.0522253, 0.0018675] # 640nm
|
||||
- [1.22417, 0.0393377, 0.00171859] # 645nm
|
||||
- [1.25762, 0.0283038, 0.00171859] # 650nm
|
||||
- [1.28362, 0.0193283, 0.00171859] # 655nm
|
||||
- [1.29877, 0.0132644, 0.00122916] # 660nm
|
||||
- [1.28855, 0.00700278, 0.00081904] # 665nm
|
||||
- [1.25385, 0.003361, 0.000511626] # 670nm
|
||||
- [1.20339, 0.0030863, -5.21537e-05] # 675nm
|
||||
- [1.1202, 0.0030863, -5.21537e-05] # 680nm
|
||||
- [1.03413, 0.0030863, -5.21537e-05] # 685nm
|
||||
- [0.93652, 0.0030863, -5.21537e-05] # 690nm
|
||||
- [0.826282, 0.00306512, -5.21537e-05] # 695nm
|
||||
- [0.699343, 0.00278782, -5.21537e-05] # 700nm
|
||||
- [0.549664, 0.00248959, -5.21537e-05] # 705nm
|
||||
- [0, 0.00242626, 0] # 710nm
|
||||
- [0, 0, 0] # 715nm
|
||||
- [0, 0, 0] # 720nm
|
||||
- [0, 0, 0] # 725nm
|
||||
- [0, 0, 0] # 730nm
|
||||
- [0, 0, 0] # 735nm
|
||||
- [0, 0, 0] # 740nm
|
||||
- [0, 0, 0] # 745nm
|
||||
- [0, 0, 0] # 750nm
|
||||
- [0, 0, 0] # 755nm
|
||||
- [0, 0, 0] # 760nm
|
||||
- [0, 0, 0] # 765nm
|
||||
- [0, 0, 0] # 770nm
|
||||
- [0, 0, 0] # 775nm
|
||||
- [0, 0, 0] # 780nm
|
||||
|
||||
# The support's own density -- film base plus, for a colour negative, the
|
||||
# orange mask. Flat zero where the datasheet does not give it.
|
||||
base_density: [0.130243, 0.130243, 0.130242, 0.130241, 0.130239, 0.130236, 0.130229, 0.130217, 0.130199, 0.13017, 0.130127, 0.130068, 0.129988, 0.129888, 0.129764, 0.12962, 0.129455, 0.129274, 0.129079, 0.128875, 0.128665, 0.128453, 0.128242, 0.128036, 0.127842, 0.127664, 0.127512, 0.127393, 0.127316, 0.127287, 0.127312, 0.127391, 0.127524, 0.127705, 0.127928, 0.128184, 0.128464, 0.128761, 0.129067, 0.129376, 0.129683, 0.129981, 0.130265, 0.13053, 0.130771, 0.130983, 0.131164, 0.131311, 0.131427, 0.131514, 0.131577, 0.131619, 0.131647, 0.131664, 0.131674, 0.131679, 0.131682, 0.131683, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684, 0.131684]
|
||||
|
||||
# The characteristic curves: density against log10 exposure, sampled
|
||||
# uniformly over [-3, 4].
|
||||
log_exposure_min: -3
|
||||
log_exposure_max: 4
|
||||
density_curves:
|
||||
- [2.7296, 3.5719, 2.8576]
|
||||
- [2.7298, 3.5721, 2.8577]
|
||||
- [2.7299, 3.5723, 2.8579]
|
||||
- [2.7302, 3.5725, 2.858]
|
||||
- [2.7303, 3.5727, 2.8583]
|
||||
- [2.7304, 3.5727, 2.8585]
|
||||
- [2.7305, 3.5727, 2.8588]
|
||||
- [2.7307, 3.5727, 2.8589]
|
||||
- [2.7308, 3.5729, 2.8588]
|
||||
- [2.7309, 3.5731, 2.8587]
|
||||
- [2.731, 3.5732, 2.8585]
|
||||
- [2.7309, 3.5731, 2.8584]
|
||||
- [2.7308, 3.5729, 2.8584]
|
||||
- [2.7306, 3.5727, 2.8584]
|
||||
- [2.7305, 3.5725, 2.8585]
|
||||
- [2.7303, 3.5725, 2.8586]
|
||||
- [2.7303, 3.5727, 2.8587]
|
||||
- [2.7303, 3.5728, 2.8588]
|
||||
- [2.7304, 3.5729, 2.8588]
|
||||
- [2.7306, 3.573, 2.8587]
|
||||
- [2.7308, 3.573, 2.8586]
|
||||
- [2.7309, 3.5729, 2.8584]
|
||||
- [2.7309, 3.5728, 2.8582]
|
||||
- [2.7308, 3.5726, 2.8582]
|
||||
- [2.7306, 3.5725, 2.8583]
|
||||
- [2.7303, 3.5725, 2.8585]
|
||||
- [2.7301, 3.5726, 2.8586]
|
||||
- [2.73, 3.5727, 2.8586]
|
||||
- [2.7302, 3.5729, 2.8585]
|
||||
- [2.7304, 3.5729, 2.8584]
|
||||
- [2.7305, 3.5728, 2.8584]
|
||||
- [2.7305, 3.5726, 2.8584]
|
||||
- [2.7305, 3.5725, 2.8586]
|
||||
- [2.7303, 3.5725, 2.8586]
|
||||
- [2.7302, 3.5727, 2.8586]
|
||||
- [2.7302, 3.5728, 2.8584]
|
||||
- [2.7303, 3.5727, 2.8581]
|
||||
- [2.7305, 3.5723, 2.8578]
|
||||
- [2.7305, 3.5717, 2.8575]
|
||||
- [2.7305, 3.571, 2.8572]
|
||||
- [2.7303, 3.5704, 2.857]
|
||||
- [2.7301, 3.5699, 2.8567]
|
||||
- [2.7298, 3.5694, 2.8563]
|
||||
- [2.7296, 3.5688, 2.8558]
|
||||
- [2.7294, 3.5678, 2.8551]
|
||||
- [2.7292, 3.5663, 2.8541]
|
||||
- [2.7289, 3.5643, 2.8527]
|
||||
- [2.7285, 3.5617, 2.851]
|
||||
- [2.7279, 3.5585, 2.849]
|
||||
- [2.7269, 3.5548, 2.8467]
|
||||
- [2.7254, 3.5504, 2.8441]
|
||||
- [2.7235, 3.5451, 2.8412]
|
||||
- [2.7212, 3.5388, 2.8377]
|
||||
- [2.7187, 3.5315, 2.8337]
|
||||
- [2.7158, 3.5227, 2.8288]
|
||||
- [2.7123, 3.512, 2.8228]
|
||||
- [2.7079, 3.4989, 2.8155]
|
||||
- [2.7024, 3.4832, 2.8071]
|
||||
- [2.6957, 3.4652, 2.7977]
|
||||
- [2.6876, 3.4451, 2.7872]
|
||||
- [2.6782, 3.4228, 2.7754]
|
||||
- [2.6674, 3.398, 2.7619]
|
||||
- [2.6549, 3.37, 2.7462]
|
||||
- [2.6407, 3.338, 2.7284]
|
||||
- [2.6243, 3.3016, 2.7085]
|
||||
- [2.6054, 3.2608, 2.6866]
|
||||
- [2.5834, 3.2158, 2.6628]
|
||||
- [2.558, 3.1668, 2.6368]
|
||||
- [2.5293, 3.1136, 2.6087]
|
||||
- [2.4974, 3.0557, 2.5783]
|
||||
- [2.4629, 2.993, 2.5458]
|
||||
- [2.4261, 2.9258, 2.5113]
|
||||
- [2.3867, 2.8551, 2.4745]
|
||||
- [2.3442, 2.7819, 2.435]
|
||||
- [2.298, 2.7069, 2.3922]
|
||||
- [2.2476, 2.6297, 2.3462]
|
||||
- [2.1936, 2.5496, 2.2973]
|
||||
- [2.1368, 2.4663, 2.2465]
|
||||
- [2.0783, 2.3804, 2.1946]
|
||||
- [2.0186, 2.2933, 2.142]
|
||||
- [1.9577, 2.2063, 2.0884]
|
||||
- [1.8953, 2.1202, 2.0334]
|
||||
- [1.8315, 2.0347, 1.9766]
|
||||
- [1.7665, 1.9497, 1.9179]
|
||||
- [1.7012, 1.8653, 1.8576]
|
||||
- [1.6363, 1.7827, 1.7963]
|
||||
- [1.572, 1.7032, 1.7343]
|
||||
- [1.5083, 1.6277, 1.6718]
|
||||
- [1.445, 1.5561, 1.6093]
|
||||
- [1.3825, 1.4873, 1.5472]
|
||||
- [1.3216, 1.4201, 1.486]
|
||||
- [1.2631, 1.3544, 1.4263]
|
||||
- [1.2075, 1.2909, 1.3681]
|
||||
- [1.1548, 1.2308, 1.3115]
|
||||
- [1.1043, 1.1746, 1.2556]
|
||||
- [1.0555, 1.1222, 1.1998]
|
||||
- [1.0081, 1.0727, 1.144]
|
||||
- [0.96198, 1.025, 1.089]
|
||||
- [0.91735, 0.97857, 1.0354]
|
||||
- [0.87431, 0.93335, 0.9839]
|
||||
- [0.83273, 0.88959, 0.93441]
|
||||
- [0.79228, 0.84747, 0.88665]
|
||||
- [0.75268, 0.80677, 0.84036]
|
||||
- [0.71399, 0.76699, 0.79552]
|
||||
- [0.67655, 0.72763, 0.75225]
|
||||
- [0.6407, 0.68852, 0.71053]
|
||||
- [0.60652, 0.6499, 0.67007]
|
||||
- [0.57358, 0.61218, 0.63043]
|
||||
- [0.54117, 0.57562, 0.59128]
|
||||
- [0.50858, 0.54018, 0.55273]
|
||||
- [0.47557, 0.50558, 0.51529]
|
||||
- [0.44242, 0.47154, 0.47956]
|
||||
- [0.40979, 0.43799, 0.44584]
|
||||
- [0.37833, 0.40504, 0.41396]
|
||||
- [0.34835, 0.37288, 0.38342]
|
||||
- [0.31989, 0.34176, 0.35366]
|
||||
- [0.29287, 0.31194, 0.32432]
|
||||
- [0.26708, 0.28347, 0.29563]
|
||||
- [0.2423, 0.25623, 0.26832]
|
||||
- [0.21841, 0.23015, 0.24318]
|
||||
- [0.1957, 0.2056, 0.22038]
|
||||
- [0.17465, 0.18321, 0.19934]
|
||||
- [0.15548, 0.16327, 0.17933]
|
||||
- [0.13794, 0.1453, 0.16011]
|
||||
- [0.12159, 0.12851, 0.14197]
|
||||
- [0.10618, 0.11239, 0.12538]
|
||||
- [0.091805, 0.096997, 0.11055]
|
||||
- [0.078824, 0.082891, 0.097247]
|
||||
- [0.067568, 0.07066, 0.085008]
|
||||
- [0.058058, 0.060504, 0.073507]
|
||||
- [0.049978, 0.052091, 0.062774]
|
||||
- [0.042873, 0.044795, 0.053117]
|
||||
- [0.036416, 0.038087, 0.044839]
|
||||
- [0.030533, 0.03182, 0.037969]
|
||||
- [0.025326, 0.026194, 0.032231]
|
||||
- [0.020872, 0.021501, 0.027229]
|
||||
- [0.017143, 0.01783, 0.022712]
|
||||
- [0.014016, 0.01501, 0.018738]
|
||||
- [0.011338, 0.01276, 0.015543]
|
||||
- [0.0089998, 0.010831, 0.013223]
|
||||
- [0.0069643, 0.0090601, 0.011549]
|
||||
- [0.005266, 0.0073789, 0.010067]
|
||||
- [0.0039654, 0.0058107, 0.0083921]
|
||||
- [0.003064, 0.004424, 0.0064482]
|
||||
- [0.0024355, 0.0032637, 0.0044813]
|
||||
- [0.0019005, 0.00231, 0.0028675]
|
||||
- [0.0013782, 0.0015172, 0.0018754]
|
||||
- [0.00091243, 0.00088058, 0.0015059]
|
||||
- [0.00059444, 0.00043535, 0.0015069]
|
||||
- [0.00045871, 0.00020435, 0.0015437]
|
||||
- [0.00044328, 0.00014741, 0.0014002]
|
||||
- [0.00044199, 0.00016489, 0.001075]
|
||||
- [0.00039236, 0.00015649, 0.00072265]
|
||||
- [0.00031388, 8.5418e-05, 0.00049885]
|
||||
- [0.00026176, -8.2573e-06, 0.00043299]
|
||||
- [0.00024502, -5.7361e-05, 0.00041378]
|
||||
- [0.0001956, -3.6951e-05, 0.00028653]
|
||||
- [2.9817e-05, 7.943e-06, -2.0676e-05]
|
||||
- [-0.00024703, 1.7376e-06, -0.00043651]
|
||||
- [-0.00049512, -8.8482e-05, -0.00078981]
|
||||
- [-0.00051672, -0.00021045, -0.00091851]
|
||||
- [-0.00021839, -0.00026647, -0.00075494]
|
||||
- [0.000311, -0.00014771, -0.00037772]
|
||||
- [0.00080653, 0.00018663, 2.9661e-05]
|
||||
- [0.00095281, 0.00062664, 0.00030567]
|
||||
- [0.00063555, 0.00096225, 0.00040618]
|
||||
- [8.565e-05, 0.0010172, 0.00041454]
|
||||
- [-0.0002699, 0.00076473, 0.00045263]
|
||||
- [-0.00016187, 0.00033972, 0.00056084]
|
||||
- [0.00028458, -5.3368e-05, 0.00065421]
|
||||
- [0.00066749, -0.00026689, 0.00060202]
|
||||
- [0.0006751, -0.00028661, 0.0003583]
|
||||
- [0.00032749, -0.00016965, -1.0676e-05]
|
||||
- [-5.4637e-05, 6.9271e-06, -0.00036219]
|
||||
- [-0.00012295, 0.00016737, -0.00055182]
|
||||
- [0.00019723, 0.0002408, -0.00051414]
|
||||
- [0.0006279, 0.00017108, -0.00030047]
|
||||
- [0.00075595, -5.2134e-05, -5.0285e-05]
|
||||
- [0.00037941, -0.00036109, 8.8967e-05]
|
||||
- [-0.00030007, -0.00061697, 4.7731e-05]
|
||||
- [-0.00080756, -0.00067629, -0.00012535]
|
||||
- [-0.0007614, -0.00048554, -0.00029509]
|
||||
- [-0.00019177, -0.00013535, -0.0003269]
|
||||
- [0.00044942, 0.00017959, -0.00017661]
|
||||
- [0.00061399, 0.00028232, 7.5181e-05]
|
||||
- [8.0134e-05, 0.00013689, 0.00027052]
|
||||
- [-0.00085313, -0.00012079, 0.00027639]
|
||||
- [-0.0015595, -0.00027701, 7.6153e-05]
|
||||
- [-0.0015328, -0.00019787, -0.0002127]
|
||||
- [-0.00075643, 6.567e-05, -0.00041069]
|
||||
- [0.00028032, 0.00030979, -0.00039294]
|
||||
- [0.00093224, 0.00033099, -0.00017231]
|
||||
- [0.00085249, 8.9964e-05, 0.0001084]
|
||||
- [0.00021756, -0.0002401, 0.00027097]
|
||||
- [-0.00043186, -0.00038632, 0.00021842]
|
||||
- [-0.00059981, -0.00017661, -5.3129e-06]
|
||||
- [-0.00018225, 0.0003153, -0.00025112]
|
||||
- [0.00049481, 0.00079004, -0.00036657]
|
||||
- [0.000945, 0.00090219, -0.00028892]
|
||||
- [0.00086621, 0.00048513, -6.6986e-05]
|
||||
- [0.00036906, -0.00028899, 0.00017012]
|
||||
- [-0.00021405, -0.0010194, 0.00033786]
|
||||
- [-0.00064117, -0.0013644, 0.00045045]
|
||||
- [-0.00084149, -0.0012406, 0.00054246]
|
||||
- [-0.00080636, -0.00080673, 0.00057742]
|
||||
- [-0.00054163, -0.00030449, 0.00047883]
|
||||
- [-0.00014085, 9.0816e-05, 0.00025062]
|
||||
- [0.00020645, 0.00032748, 2.2587e-05]
|
||||
- [0.00039606, 0.00042477, -7.1076e-05]
|
||||
- [0.00046421, 0.00045154, -3.5319e-06]
|
||||
- [0.00045927, 0.00050805, 0.00014307]
|
||||
- [0.00041221, 0.00062938, 0.00021518]
|
||||
- [0.00032428, 0.00075855, 9.2604e-05]
|
||||
- [0.00019112, 0.00078669, -0.0002217]
|
||||
- [3.4831e-05, 0.00063167, -0.00059374]
|
||||
- [-8.8526e-05, 0.00030183, -0.00084102]
|
||||
- [-0.00011636, -9.7907e-05, -0.00084713]
|
||||
- [-2.3143e-05, -0.00042194, -0.00062749]
|
||||
- [0.00015297, -0.00056606, -0.00030374]
|
||||
- [0.00032266, -0.0005232, -1.3704e-05]
|
||||
- [0.00039221, -0.00037849, 0.00017119]
|
||||
- [0.00031597, -0.00024908, 0.00026249]
|
||||
- [0.00012212, -0.00020961, 0.00030531]
|
||||
- [-0.00010306, -0.00025131, 0.00031688]
|
||||
- [-0.00026154, -0.000299, 0.00027163]
|
||||
- [-0.00029259, -0.00027061, 0.00014416]
|
||||
- [-0.000198, -0.00013689, -3.495e-05]
|
||||
- [-3.3834e-05, 5.8756e-05, -0.00017183]
|
||||
- [0.00012171, 0.00023137, -0.00017006]
|
||||
- [0.00020459, 0.00031205, -1.5488e-05]
|
||||
- [0.00018864, 0.00029076, 0.00018992]
|
||||
- [9.0619e-05, 0.00021548, 0.00028646]
|
||||
- [-3.9992e-05, 0.00015062, 0.00017361]
|
||||
- [-0.00013957, 0.00012675, -0.00010314]
|
||||
- [-0.00015232, 0.00011888, -0.00037067]
|
||||
- [-5.2034e-05, 6.9298e-05, -0.00044858]
|
||||
- [0.00014165, -6.1556e-05, -0.00028559]
|
||||
- [0.00036103, -0.00025352, -5.6164e-06]
|
||||
- [0.00050974, -0.00042611, 0.00017901]
|
||||
- [0.0005025, -0.00048282, 0.00012788]
|
||||
- [0.00031031, -0.00037305, -0.0001142]
|
||||
- [-1.2991e-05, -0.00013156, -0.00034072]
|
||||
- [-0.00034329, 0.00013132, -0.00033198]
|
||||
- [-0.00053914, 0.00028491, -1.9892e-05]
|
||||
- [-0.00051235, 0.00024706, 0.00045446]
|
||||
- [-0.00027822, 2.7433e-05, 0.00083498]
|
||||
- [4.533e-05, -0.00027584, 0.0009199]
|
||||
- [0.00028471, -0.0005081, 0.00068138]
|
||||
- [0.00031968, -0.0005594, 0.00034871]
|
||||
- [0.00017724, -0.00042893, 0.00014744]
|
||||
- [-1.6195e-05, -0.00019955, 9.3372e-05]
|
||||
- [-0.00015589, 3.0964e-05, 5.2417e-05]
|
||||
- [-0.00023099, 0.00020411, -6.4328e-05]
|
||||
- [-0.00028939, 0.00031064, -0.00021919]
|
||||
- [-0.00035502, 0.00036863, -0.00033587]
|
||||
- [-0.00039868, 0.00039829, -0.00039436]
|
||||
@@ -1,453 +0,0 @@
|
||||
# Generated by tools/film-profiles/convert.py from spektrafilm.
|
||||
# Do not edit by hand: re-run the converter instead.
|
||||
#
|
||||
# spektrafilm by Andrea Volpato, https://github.com/andreavolpato/spektrafilm
|
||||
# Licensed CC BY-SA 4.0. Modified for DarkRoom: trimmed to the fields the
|
||||
# renderer uses and reformatted; see profiles/CHANGELOG.txt.
|
||||
|
||||
version: '0.3.2'
|
||||
stock: fujifilm_xtra_400
|
||||
name: 'Fujifilm X-Tra 400'
|
||||
kind: negative # negative | positive
|
||||
support: film # film | paper
|
||||
stage: filming # filming | printing
|
||||
monochrome: false
|
||||
reference_illuminant: D55
|
||||
viewing_illuminant: D50
|
||||
target_print: fujifilm_crystal_archive_typeii
|
||||
|
||||
# log10 spectral sensitivity per layer, 380-780nm at 5nm, in R,G,B layer
|
||||
# order. A null upstream means the datasheet has no reading there, which is
|
||||
# blindness, so it is written as the sentinel the loader reads as such.
|
||||
log_sensitivity:
|
||||
- [-4.38865, -4.19425, -2.02729] # 380nm
|
||||
- [-4.36119, -4.12743, -1.81374] # 385nm
|
||||
- [-4.33273, -4.05719, -1.57986] # 390nm
|
||||
- [-4.30322, -3.98325, -1.32258] # 395nm
|
||||
- [-4.27259, -3.90532, -1.03823] # 400nm
|
||||
- [-4.24079, -3.82305, -0.742603] # 405nm
|
||||
- [-4.20774, -3.73609, -0.614667] # 410nm
|
||||
- [-4.17336, -3.644, -0.551248] # 415nm
|
||||
- [-4.13759, -3.54634, -0.521058] # 420nm
|
||||
- [-4.10032, -3.44258, -0.509634] # 425nm
|
||||
- [-4.06147, -3.33212, -0.506458] # 430nm
|
||||
- [-4.02092, -3.21429, -0.504602] # 435nm
|
||||
- [-3.97858, -3.08834, -0.503705] # 440nm
|
||||
- [-3.93431, -2.95339, -0.501026] # 445nm
|
||||
- [-3.88798, -2.80845, -0.491563] # 450nm
|
||||
- [-3.83945, -2.65236, -0.468842] # 455nm
|
||||
- [-3.78855, -2.48378, -0.427724] # 460nm
|
||||
- [-3.7351, -2.30115, -0.360532] # 465nm
|
||||
- [-3.67891, -2.10264, -0.314344] # 470nm
|
||||
- [-3.61977, -1.88609, -0.322937] # 475nm
|
||||
- [-3.55743, -1.66204, -0.395587] # 480nm
|
||||
- [-3.49162, -1.45228, -0.588307] # 485nm
|
||||
- [-3.42206, -1.27844, -0.966605] # 490nm
|
||||
- [-3.3484, -1.13012, -1.67805] # 495nm
|
||||
- [-3.27028, -1.00757, -2.3471] # 500nm
|
||||
- [-3.18727, -0.927043, -2.95244] # 505nm
|
||||
- [-3.09891, -0.856777, -3.50274] # 510nm
|
||||
- [-3.00466, -0.800281, -4.00519] # 515nm
|
||||
- [-2.90391, -0.750453, -4.46576] # 520nm
|
||||
- [-2.79597, -0.672648, -4.8895] # 525nm
|
||||
- [-2.68003, -0.577175, -5.28063] # 530nm
|
||||
- [-2.55517, -0.485456, -5.6428] # 535nm
|
||||
- [-2.42032, -0.405656, -5.97909] # 540nm
|
||||
- [-2.27423, -0.338931, -6.29219] # 545nm
|
||||
- [-2.11544, -0.289217, -6.58442] # 550nm
|
||||
- [-1.94221, -0.261427, -6.8578] # 555nm
|
||||
- [-1.75249, -0.264028, -7.11409] # 560nm
|
||||
- [-1.54379, -0.303718, -7.35484] # 565nm
|
||||
- [-1.31313, -0.425622, -7.58144] # 570nm
|
||||
- [-1.07244, -0.734317, -7.79508] # 575nm
|
||||
- [-0.882812, -1.15024, -7.99686] # 580nm
|
||||
- [-0.717011, -1.6282, -8.18773] # 585nm
|
||||
- [-0.599759, -2.08148, -8.36856] # 590nm
|
||||
- [-0.504649, -2.49698, -8.54011] # 595nm
|
||||
- [-0.429596, -2.87924, -8.70308] # 600nm
|
||||
- [-0.378624, -3.2321, -8.8581] # 605nm
|
||||
- [-0.350262, -3.55882, -9.00575] # 610nm
|
||||
- [-0.336925, -3.8622, -9.14652] # 615nm
|
||||
- [-0.326672, -4.14466, -9.2809] # 620nm
|
||||
- [-0.319524, -4.40829, -9.4093] # 625nm
|
||||
- [-0.318332, -4.65491, -9.53212] # 630nm
|
||||
- [-0.328513, -4.88611, -9.64972] # 635nm
|
||||
- [-0.372299, -5.10331, -9.76241] # 640nm
|
||||
- [-0.538776, -5.30773, -9.8705] # 645nm
|
||||
- [-0.779935, -5.50046, -9.97428] # 650nm
|
||||
- [-1.03199, -5.68249, -10.074] # 655nm
|
||||
- [-1.2983, -5.85468, -10.1698] # 660nm
|
||||
- [-1.55342, -6.01781, -10.2621] # 665nm
|
||||
- [-1.79738, -6.17257, -10.3509] # 670nm
|
||||
- [-2.0181, -6.3196, -10.4365] # 675nm
|
||||
- [-2.21876, -6.45945, -10.5191] # 680nm
|
||||
- [-2.40196, -6.59264, -10.5987] # 685nm
|
||||
- [-2.5699, -6.71964, -10.6756] # 690nm
|
||||
- [-2.72441, -6.84086, -10.7499] # 695nm
|
||||
- [-2.86703, -6.9567, -10.8217] # 700nm
|
||||
- [-2.99909, -7.0675, -10.8912] # 705nm
|
||||
- [-3.12171, -7.17359, -10.9584] # 710nm
|
||||
- [-3.23588, -7.27525, -11.0235] # 715nm
|
||||
- [-3.34243, -7.37277, -11.0866] # 720nm
|
||||
- [-3.44211, -7.46638, -11.1477] # 725nm
|
||||
- [-3.53556, -7.55633, -11.2069] # 730nm
|
||||
- [-3.62335, -7.64281, -11.2644] # 735nm
|
||||
- [-3.70597, -7.72603, -11.3202] # 740nm
|
||||
- [-3.78387, -7.80617, -11.3744] # 745nm
|
||||
- [-3.85745, -7.8834, -11.4271] # 750nm
|
||||
- [-3.92705, -7.95786, -11.4782] # 755nm
|
||||
- [-3.99298, -8.02972, -11.528] # 760nm
|
||||
- [-4.05553, -8.09909, -11.5763] # 765nm
|
||||
- [-4.11496, -8.16612, -11.6234] # 770nm
|
||||
- [-4.17148, -8.23091, -11.6692] # 775nm
|
||||
- [-4.22532, -8.29357, -11.7138] # 780nm
|
||||
|
||||
# Spectral density of each layer's dye at unit density, same grid and order.
|
||||
dye_density:
|
||||
- [0, 0, 0] # 380nm
|
||||
- [0.222857, 0.0732304, 0.210496] # 385nm
|
||||
- [0.1836, 0.0663671, 0.296593] # 390nm
|
||||
- [0.144481, 0.0584631, 0.381601] # 395nm
|
||||
- [0.106515, 0.0497767, 0.469805] # 400nm
|
||||
- [0.0707021, 0.0408295, 0.563757] # 405nm
|
||||
- [0.0383167, 0.0321522, 0.662611] # 410nm
|
||||
- [0.0108064, 0.0239874, 0.763544] # 415nm
|
||||
- [-0.0105008, 0.0161607, 0.863165] # 420nm
|
||||
- [-0.0246604, 0.00822761, 0.957597] # 425nm
|
||||
- [-0.0314231, -0.000208947, 1.04222] # 430nm
|
||||
- [-0.0313872, -0.00918302, 1.11207] # 435nm
|
||||
- [-0.0258609, -0.018075, 1.16292] # 440nm
|
||||
- [-0.0165886, -0.025369, 1.19202] # 445nm
|
||||
- [-0.00543822, -0.0286668, 1.19759] # 450nm
|
||||
- [0.00591591, -0.0252879, 1.17828] # 455nm
|
||||
- [0.0162112, -0.0132668, 1.13351] # 460nm
|
||||
- [0.024653, 0.00823095, 1.0642] # 465nm
|
||||
- [0.0308451, 0.0398857, 0.973202] # 470nm
|
||||
- [0.0346695, 0.0835264, 0.865355] # 475nm
|
||||
- [0.0362171, 0.141733, 0.74699] # 480nm
|
||||
- [0.0357123, 0.215914, 0.624896] # 485nm
|
||||
- [0.0334214, 0.305395, 0.505029] # 490nm
|
||||
- [0.0295551, 0.408113, 0.392105] # 495nm
|
||||
- [0.0242436, 0.521275, 0.290072] # 500nm
|
||||
- [0.017624, 0.641022, 0.20217] # 505nm
|
||||
- [0.00996428, 0.762284, 0.13043] # 510nm
|
||||
- [0.00170299, 0.879801, 0.0752484] # 515nm
|
||||
- [-0.00659919, 0.989197, 0.0355036] # 520nm
|
||||
- [-0.0143022, 1.08703, 0.00905612] # 525nm
|
||||
- [-0.0206663, 1.17018, -0.00664406] # 530nm
|
||||
- [-0.0247841, 1.23496, -0.0140237] # 535nm
|
||||
- [-0.025552, 1.27676, -0.0152636] # 540nm
|
||||
- [-0.0217759, 1.29161, -0.0124342] # 545nm
|
||||
- [-0.012344, 1.27808, -0.00748786] # 550nm
|
||||
- [0.00372297, 1.23749, -0.00195166] # 555nm
|
||||
- [0.0272996, 1.17235, 0.00323949] # 560nm
|
||||
- [0.0590749, 1.08548, 0.00761413] # 565nm
|
||||
- [0.0993119, 0.980649, 0.0109788] # 570nm
|
||||
- [0.147578, 0.863617, 0.0133374] # 575nm
|
||||
- [0.202633, 0.74216, 0.0148713] # 580nm
|
||||
- [0.262633, 0.624435, 0.0158757] # 585nm
|
||||
- [0.325724, 0.516634, 0.016591] # 590nm
|
||||
- [0.390554, 0.422011, 0.0170299] # 595nm
|
||||
- [0.456294, 0.341727, 0.0169743] # 600nm
|
||||
- [0.522326, 0.275784, 0.0161441] # 605nm
|
||||
- [0.587938, 0.223202, 0.0143899] # 610nm
|
||||
- [0.652331, 0.181977, 0.0118381] # 615nm
|
||||
- [0.714945, 0.149426, 0.00892776] # 620nm
|
||||
- [0.775739, 0.122974, 0.00626222] # 625nm
|
||||
- [0.83503, 0.100901, 0.00428799] # 630nm
|
||||
- [0.892968, 0.0823296, 0.0030565] # 635nm
|
||||
- [0.949244, 0.06678, 0.00233122] # 640nm
|
||||
- [1.00339, 0.0539257, 0.00184422] # 645nm
|
||||
- [1.05519, 0.0435951, 0.0014387] # 650nm
|
||||
- [1.10467, 0.0357793, 0.00106805] # 655nm
|
||||
- [1.15171, 0.0305464, 0.000739508] # 660nm
|
||||
- [1.19565, 0.0278597, 0.000468868] # 665nm
|
||||
- [1.2352, 0.0274199, 0.00026172] # 670nm
|
||||
- [1.2688, 0.0286469, 0.000111234] # 675nm
|
||||
- [1.29539, 0.0308117, 1.88206e-06] # 680nm
|
||||
- [1.31483, 0.0332177, -1.49903e-09] # 685nm
|
||||
- [1.32768, 0.0353511, -5.12292e-10] # 690nm
|
||||
- [1.3342, 0.0369464, -1.69837e-10] # 695nm
|
||||
- [1.33373, 0.0379395, -5.46205e-11] # 700nm
|
||||
- [1.32494, 0.0383508, -1.70407e-11] # 705nm
|
||||
- [1.30654, 0.0382531, -5.15739e-12] # 710nm
|
||||
- [1.27792, 0.037777, -1.51419e-12] # 715nm
|
||||
- [1.23946, 0.0370649, -4.31261e-13] # 720nm
|
||||
- [1.19244, 0.0362405, -1.19154e-13] # 725nm
|
||||
- [1.13855, 0.0354007, 8.39336e-05] # 730nm
|
||||
- [1.07941, 0.0346195, 0.000203042] # 735nm
|
||||
- [1.01631, 0.033957, 0.000259093] # 740nm
|
||||
- [0.950466, 0.0334664, 0.00021304] # 745nm
|
||||
- [0, 0.0332, 2.53831e-05] # 750nm
|
||||
- [0, 0, 0] # 755nm
|
||||
- [0, 0, 0] # 760nm
|
||||
- [0, 0, 0] # 765nm
|
||||
- [0, 0, 0] # 770nm
|
||||
- [0, 0, 0] # 775nm
|
||||
- [0, 0, 0] # 780nm
|
||||
|
||||
# The support's own density -- film base plus, for a colour negative, the
|
||||
# orange mask. Flat zero where the datasheet does not give it.
|
||||
base_density: [0, 0, 0, 0, 0, 0.848553, 0.827517, 0.819464, 0.823125, 0.833258, 0.842166, 0.848946, 0.849583, 0.842719, 0.833291, 0.8224, 0.80986, 0.795933, 0.781364, 0.765776, 0.750072, 0.733818, 0.717994, 0.701974, 0.687197, 0.674231, 0.663412, 0.654042, 0.645163, 0.63596, 0.626271, 0.615854, 0.604626, 0.592353, 0.579418, 0.566238, 0.552306, 0.534826, 0.504085, 0.467685, 0.429387, 0.391086, 0.353535, 0.318547, 0.287102, 0.262974, 0.250192, 0.24397, 0.240039, 0.239892, 0.241816, 0.245818, 0.25127, 0.257803, 0.265157, 0.272354, 0.279618, 0.283315, 0.281583, 0.276365, 0.269959, 0.262804, 0.255661, 0.248294, 0.240291, 0.23244, 0.22412, 0.215489, 0.207355, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
|
||||
# The characteristic curves: density against log10 exposure, sampled
|
||||
# uniformly over [-3, 4].
|
||||
log_exposure_min: -3
|
||||
log_exposure_max: 4
|
||||
density_curves:
|
||||
- [-0.0006236, -0.00080906, -0.00082678]
|
||||
- [-0.00046664, -0.00066677, -0.0007417]
|
||||
- [-0.00022623, -0.00042245, -0.00059259]
|
||||
- [3.7008e-05, -0.00016846, -0.00040163]
|
||||
- [0.00026863, -1.0513e-05, -0.0001715]
|
||||
- [0.00045523, 1.277e-05, 8.5916e-05]
|
||||
- [0.00063244, -2.0835e-05, 0.0003126]
|
||||
- [0.00084724, 2.0772e-05, 0.0004233]
|
||||
- [0.0011044, 0.00019158, 0.0003768]
|
||||
- [0.001353, 0.00040388, 0.00022179]
|
||||
- [0.0015324, 0.00050991, 5.9035e-05]
|
||||
- [0.0016284, 0.00044275, -4.2241e-05]
|
||||
- [0.0016624, 0.00024912, -6.1931e-05]
|
||||
- [0.0016852, 4.5503e-05, -1.6017e-05]
|
||||
- [0.0017526, -5.8042e-05, 6.9195e-05]
|
||||
- [0.0019035, -1.9084e-05, 0.00017446]
|
||||
- [0.0021565, 0.00012902, 0.0002836]
|
||||
- [0.0025201, 0.00031566, 0.00036951]
|
||||
- [0.0030003, 0.00048013, 0.00039248]
|
||||
- [0.0035939, 0.00059285, 0.0003218]
|
||||
- [0.0042718, 0.00064656, 0.00016681]
|
||||
- [0.0049714, 0.00064105, -1.0867e-05]
|
||||
- [0.005617, 0.00058452, -0.00012238]
|
||||
- [0.006163, 0.00051029, -0.00010367]
|
||||
- [0.0066359, 0.00048386, 4.0655e-05]
|
||||
- [0.0071409, 0.00057853, 0.00023572]
|
||||
- [0.0078216, 0.00082654, 0.00038279]
|
||||
- [0.0087877, 0.0011832, 0.00042406]
|
||||
- [0.010057, 0.0015411, 0.00038058]
|
||||
- [0.011545, 0.0017982, 0.00033619]
|
||||
- [0.013124, 0.0019352, 0.00037847]
|
||||
- [0.014695, 0.0020432, 0.00053807]
|
||||
- [0.016259, 0.0022725, 0.00076861]
|
||||
- [0.017909, 0.0027281, 0.00097907]
|
||||
- [0.019781, 0.0033869, 0.0010942]
|
||||
- [0.021973, 0.0040998, 0.0011008]
|
||||
- [0.024495, 0.0046853, 0.0010517]
|
||||
- [0.027278, 0.0050554, 0.0010304]
|
||||
- [0.030222, 0.0052872, 0.001106]
|
||||
- [0.033259, 0.005589, 0.0013071]
|
||||
- [0.036396, 0.0061813, 0.0016247]
|
||||
- [0.0397, 0.0071707, 0.0020313]
|
||||
- [0.043277, 0.0084992, 0.0024944]
|
||||
- [0.047228, 0.009994, 0.0029779]
|
||||
- [0.051625, 0.011478, 0.0034413]
|
||||
- [0.056493, 0.012867, 0.0038505]
|
||||
- [0.061797, 0.014193, 0.0042044]
|
||||
- [0.067437, 0.015566, 0.0045575]
|
||||
- [0.073262, 0.017106, 0.0050152]
|
||||
- [0.079123, 0.01891, 0.0056873]
|
||||
- [0.084952, 0.021023, 0.006608]
|
||||
- [0.090888, 0.023463, 0.0077542]
|
||||
- [0.097263, 0.026282, 0.0091107]
|
||||
- [0.1044, 0.029495, 0.010647]
|
||||
- [0.11234, 0.032966, 0.012257]
|
||||
- [0.1208, 0.036414, 0.013807]
|
||||
- [0.12945, 0.039629, 0.015281]
|
||||
- [0.13815, 0.042722, 0.016866]
|
||||
- [0.14702, 0.046166, 0.018834]
|
||||
- [0.1562, 0.050467, 0.021278]
|
||||
- [0.16588, 0.055863, 0.024055]
|
||||
- [0.17625, 0.062183, 0.026932]
|
||||
- [0.18739, 0.068942, 0.029767]
|
||||
- [0.19926, 0.075661, 0.032608]
|
||||
- [0.21157, 0.082171, 0.035649]
|
||||
- [0.22392, 0.088685, 0.03909]
|
||||
- [0.23591, 0.095585, 0.043018]
|
||||
- [0.2474, 0.10308, 0.047413]
|
||||
- [0.25869, 0.11101, 0.05224]
|
||||
- [0.27039, 0.11901, 0.05755]
|
||||
- [0.28316, 0.12688, 0.063466]
|
||||
- [0.29729, 0.13483, 0.070056]
|
||||
- [0.31249, 0.14351, 0.077202]
|
||||
- [0.32799, 0.15352, 0.084602]
|
||||
- [0.34297, 0.165, 0.091945]
|
||||
- [0.35708, 0.17742, 0.099147]
|
||||
- [0.37061, 0.18994, 0.10648]
|
||||
- [0.3843, 0.20195, 0.11445]
|
||||
- [0.39884, 0.21352, 0.12356]
|
||||
- [0.41441, 0.22526, 0.13398]
|
||||
- [0.43065, 0.23782, 0.14547]
|
||||
- [0.44692, 0.25135, 0.15761]
|
||||
- [0.4628, 0.26536, 0.16998]
|
||||
- [0.47831, 0.27914, 0.18245]
|
||||
- [0.49381, 0.29247, 0.19507]
|
||||
- [0.50958, 0.30586, 0.20797]
|
||||
- [0.52552, 0.32026, 0.22122]
|
||||
- [0.54118, 0.33639, 0.23488]
|
||||
- [0.55615, 0.35398, 0.24904]
|
||||
- [0.57047, 0.37177, 0.26385]
|
||||
- [0.58465, 0.38838, 0.27955]
|
||||
- [0.59936, 0.40335, 0.29639]
|
||||
- [0.615, 0.41741, 0.31451]
|
||||
- [0.63144, 0.43197, 0.33364]
|
||||
- [0.64818, 0.44806, 0.35305]
|
||||
- [0.66461, 0.46574, 0.37194]
|
||||
- [0.68037, 0.48419, 0.39005]
|
||||
- [0.69547, 0.50232, 0.40785]
|
||||
- [0.71024, 0.51952, 0.42604]
|
||||
- [0.72496, 0.53595, 0.44511]
|
||||
- [0.73965, 0.55231, 0.46509]
|
||||
- [0.75408, 0.56916, 0.48568]
|
||||
- [0.76812, 0.58649, 0.5066]
|
||||
- [0.78198, 0.60375, 0.52784]
|
||||
- [0.79627, 0.62035, 0.54956]
|
||||
- [0.81164, 0.63614, 0.57179]
|
||||
- [0.82835, 0.65158, 0.59421]
|
||||
- [0.84596, 0.66741, 0.61622]
|
||||
- [0.86352, 0.6841, 0.63738]
|
||||
- [0.88007, 0.70153, 0.65776]
|
||||
- [0.89514, 0.71918, 0.67801]
|
||||
- [0.90904, 0.73646, 0.699]
|
||||
- [0.9226, 0.75306, 0.72119]
|
||||
- [0.93667, 0.76901, 0.74436]
|
||||
- [0.95164, 0.78445, 0.76775]
|
||||
- [0.96751, 0.79963, 0.79039]
|
||||
- [0.98403, 0.81482, 0.81163]
|
||||
- [1.0008, 0.82999, 0.83162]
|
||||
- [1.0173, 0.84471, 0.8515]
|
||||
- [1.0331, 0.8587, 0.8726]
|
||||
- [1.0486, 0.87255, 0.89526]
|
||||
- [1.0646, 0.88753, 0.91852]
|
||||
- [1.0815, 0.9043, 0.94101]
|
||||
- [1.0989, 0.92197, 0.96216]
|
||||
- [1.116, 0.93888, 0.98251]
|
||||
- [1.132, 0.95383, 1.003]
|
||||
- [1.1472, 0.96689, 1.0242]
|
||||
- [1.1625, 0.97942, 1.0458]
|
||||
- [1.1789, 0.99312, 1.0668]
|
||||
- [1.1967, 1.0089, 1.0865]
|
||||
- [1.2154, 1.0262, 1.1051]
|
||||
- [1.2341, 1.0437, 1.1234]
|
||||
- [1.252, 1.0601, 1.1425]
|
||||
- [1.269, 1.0751, 1.1627]
|
||||
- [1.2856, 1.0894, 1.1836]
|
||||
- [1.3023, 1.1044, 1.2044]
|
||||
- [1.3192, 1.1206, 1.2245]
|
||||
- [1.3362, 1.1377, 1.2443]
|
||||
- [1.3531, 1.1552, 1.2647]
|
||||
- [1.3697, 1.1723, 1.2864]
|
||||
- [1.3859, 1.1888, 1.3089]
|
||||
- [1.4022, 1.2045, 1.3308]
|
||||
- [1.4189, 1.2196, 1.351]
|
||||
- [1.4362, 1.2346, 1.3693]
|
||||
- [1.4538, 1.2497, 1.3867]
|
||||
- [1.4711, 1.265, 1.4046]
|
||||
- [1.4879, 1.2805, 1.4241]
|
||||
- [1.5044, 1.2962, 1.4455]
|
||||
- [1.5211, 1.3124, 1.468]
|
||||
- [1.5381, 1.3291, 1.4903]
|
||||
- [1.5552, 1.3464, 1.5118]
|
||||
- [1.5721, 1.3639, 1.5325]
|
||||
- [1.5885, 1.3812, 1.5529]
|
||||
- [1.6046, 1.3981, 1.5739]
|
||||
- [1.6205, 1.4149, 1.5953]
|
||||
- [1.6363, 1.4318, 1.617]
|
||||
- [1.6518, 1.449, 1.6383]
|
||||
- [1.6666, 1.4662, 1.6589]
|
||||
- [1.6807, 1.4832, 1.6791]
|
||||
- [1.6948, 1.4998, 1.6995]
|
||||
- [1.7095, 1.5163, 1.7209]
|
||||
- [1.725, 1.5329, 1.7435]
|
||||
- [1.741, 1.5503, 1.767]
|
||||
- [1.7567, 1.5684, 1.7907]
|
||||
- [1.7709, 1.5868, 1.8139]
|
||||
- [1.7832, 1.6047, 1.8363]
|
||||
- [1.7946, 1.6215, 1.8584]
|
||||
- [1.8063, 1.6369, 1.8807]
|
||||
- [1.8193, 1.6516, 1.9032]
|
||||
- [1.833, 1.6663, 1.9256]
|
||||
- [1.8463, 1.6816, 1.9473]
|
||||
- [1.858, 1.6975, 1.9683]
|
||||
- [1.8685, 1.7138, 1.9887]
|
||||
- [1.8785, 1.7301, 2.0091]
|
||||
- [1.8892, 1.7462, 2.03]
|
||||
- [1.9007, 1.7618, 2.0518]
|
||||
- [1.9122, 1.7767, 2.0741]
|
||||
- [1.9225, 1.7909, 2.0964]
|
||||
- [1.9312, 1.8045, 2.1181]
|
||||
- [1.9388, 1.8182, 2.1389]
|
||||
- [1.9466, 1.8324, 2.1591]
|
||||
- [1.9556, 1.8472, 2.1791]
|
||||
- [1.9656, 1.8623, 2.1994]
|
||||
- [1.9754, 1.8771, 2.2202]
|
||||
- [1.9837, 1.8908, 2.2411]
|
||||
- [1.9901, 1.9035, 2.2616]
|
||||
- [1.9954, 1.9156, 2.2811]
|
||||
- [2.0009, 1.9278, 2.2996]
|
||||
- [2.0079, 1.9404, 2.3175]
|
||||
- [2.0162, 1.9534, 2.3355]
|
||||
- [2.0247, 1.966, 2.3539]
|
||||
- [2.032, 1.9777, 2.3727]
|
||||
- [2.0376, 1.9882, 2.3915]
|
||||
- [2.0419, 1.9983, 2.4094]
|
||||
- [2.0459, 2.0086, 2.4263]
|
||||
- [2.0507, 2.0197, 2.4423]
|
||||
- [2.0563, 2.0313, 2.458]
|
||||
- [2.0621, 2.0424, 2.4737]
|
||||
- [2.0672, 2.0523, 2.4896]
|
||||
- [2.0712, 2.0605, 2.5057]
|
||||
- [2.0743, 2.0675, 2.5214]
|
||||
- [2.0771, 2.0744, 2.5365]
|
||||
- [2.08, 2.0821, 2.551]
|
||||
- [2.0831, 2.0906, 2.5651]
|
||||
- [2.0864, 2.0996, 2.5787]
|
||||
- [2.0898, 2.1084, 2.5915]
|
||||
- [2.0933, 2.1167, 2.6036]
|
||||
- [2.0965, 2.1243, 2.6153]
|
||||
- [2.0993, 2.1313, 2.627]
|
||||
- [2.1018, 2.1379, 2.6388]
|
||||
- [2.104, 2.1443, 2.6504]
|
||||
- [2.1061, 2.1505, 2.6614]
|
||||
- [2.1079, 2.1565, 2.6716]
|
||||
- [2.1096, 2.1621, 2.6809]
|
||||
- [2.1112, 2.1671, 2.6898]
|
||||
- [2.1126, 2.1715, 2.6987]
|
||||
- [2.1141, 2.1756, 2.7078]
|
||||
- [2.1157, 2.1796, 2.717]
|
||||
- [2.1172, 2.1837, 2.726]
|
||||
- [2.1187, 2.188, 2.7346]
|
||||
- [2.1199, 2.1923, 2.7427]
|
||||
- [2.1209, 2.1963, 2.7502]
|
||||
- [2.1217, 2.1999, 2.7573]
|
||||
- [2.1224, 2.2032, 2.7641]
|
||||
- [2.123, 2.2064, 2.7704]
|
||||
- [2.1238, 2.2095, 2.7763]
|
||||
- [2.1246, 2.2126, 2.7818]
|
||||
- [2.1255, 2.2156, 2.7871]
|
||||
- [2.1263, 2.2184, 2.7924]
|
||||
- [2.127, 2.2209, 2.7977]
|
||||
- [2.1275, 2.2231, 2.8028]
|
||||
- [2.1279, 2.2252, 2.8074]
|
||||
- [2.1282, 2.2271, 2.8115]
|
||||
- [2.1285, 2.2289, 2.8152]
|
||||
- [2.1289, 2.2307, 2.8186]
|
||||
- [2.1293, 2.2323, 2.8221]
|
||||
- [2.1299, 2.2336, 2.8258]
|
||||
- [2.1304, 2.2348, 2.8294]
|
||||
- [2.1308, 2.236, 2.8327]
|
||||
- [2.1311, 2.2372, 2.8354]
|
||||
- [2.1311, 2.2385, 2.8378]
|
||||
- [2.1309, 2.2399, 2.84]
|
||||
- [2.1308, 2.2412, 2.8424]
|
||||
- [2.1308, 2.2423, 2.845]
|
||||
- [2.1309, 2.2431, 2.8477]
|
||||
- [2.1313, 2.2437, 2.8501]
|
||||
- [2.1318, 2.2441, 2.852]
|
||||
- [2.1321, 2.2445, 2.8533]
|
||||
- [2.1323, 2.2451, 2.8545]
|
||||
- [2.1322, 2.2458, 2.8557]
|
||||
- [2.1321, 2.2466, 2.857]
|
||||
- [2.132, 2.2474, 2.8582]
|
||||
- [2.132, 2.248, 2.8592]
|
||||
- [2.132, 2.2485, 2.8601]
|
||||
- [2.132, 2.249, 2.861]
|
||||
- [2.132, 2.2494, 2.8618]
|
||||
@@ -1,460 +0,0 @@
|
||||
# Generated by tools/film-profiles/convert.py.
|
||||
#
|
||||
# *** CONSTRUCTED, NOT MEASURED. ***
|
||||
#
|
||||
# Ilford publish this film's spectral sensitivity and characteristic
|
||||
# curve as graphs, and no granularity figure at all, so this profile is
|
||||
# built rather than extracted. Its speed is the published ISO rating,
|
||||
# and its contrast is ISO 6:1993's normal development (average gradient
|
||||
# 0.62). Its spectral response is borrowed from Kodak Double-X, a
|
||||
# *measured* panchromatic negative, shifted by the speed difference.
|
||||
# Its granularity is an estimate, ordered against the other films here.
|
||||
#
|
||||
# So it renders as a film of this speed and contrast, not as a
|
||||
# measurement of this emulsion. Replace it outright if anyone digitises
|
||||
# the real curves.
|
||||
|
||||
version: 'constructed-1'
|
||||
stock: ilford_delta_100
|
||||
name: 'Ilford Delta 100'
|
||||
kind: negative # negative | positive
|
||||
support: film # film | paper
|
||||
stage: filming # filming | printing
|
||||
monochrome: true
|
||||
target_print: kodak_2302
|
||||
reference_illuminant: D55
|
||||
viewing_illuminant: D50
|
||||
|
||||
rms_granularity: [6, 6, 6]
|
||||
|
||||
# log10 spectral sensitivity per layer, 380-780nm at 5nm.
|
||||
log_sensitivity:
|
||||
- [-9, -9, -9] # 380nm
|
||||
- [-9, -9, -9] # 385nm
|
||||
- [-9, -9, -9] # 390nm
|
||||
- [-9, -9, -9] # 395nm
|
||||
- [-9, -9, -9] # 400nm
|
||||
- [-9, -9, -9] # 405nm
|
||||
- [-9, -9, -9] # 410nm
|
||||
- [-9, -9, -9] # 415nm
|
||||
- [-9, -9, -9] # 420nm
|
||||
- [-1.11516, -1.11516, -1.11516] # 425nm
|
||||
- [-1.1149, -1.1149, -1.1149] # 430nm
|
||||
- [-1.1149, -1.1149, -1.1149] # 435nm
|
||||
- [-1.11525, -1.11525, -1.11525] # 440nm
|
||||
- [-1.12495, -1.12495, -1.12495] # 445nm
|
||||
- [-1.13768, -1.13768, -1.13768] # 450nm
|
||||
- [-1.15447, -1.15447, -1.15447] # 455nm
|
||||
- [-1.17373, -1.17373, -1.17373] # 460nm
|
||||
- [-1.19518, -1.19518, -1.19518] # 465nm
|
||||
- [-1.21361, -1.21361, -1.21361] # 470nm
|
||||
- [-1.2395, -1.2395, -1.2395] # 475nm
|
||||
- [-1.26728, -1.26728, -1.26728] # 480nm
|
||||
- [-1.30522, -1.30522, -1.30522] # 485nm
|
||||
- [-1.34497, -1.34497, -1.34497] # 490nm
|
||||
- [-1.38465, -1.38465, -1.38465] # 495nm
|
||||
- [-1.41792, -1.41792, -1.41792] # 500nm
|
||||
- [-1.4332, -1.4332, -1.4332] # 505nm
|
||||
- [-1.43637, -1.43637, -1.43637] # 510nm
|
||||
- [-1.43566, -1.43566, -1.43566] # 515nm
|
||||
- [-1.42589, -1.42589, -1.42589] # 520nm
|
||||
- [-1.41287, -1.41287, -1.41287] # 525nm
|
||||
- [-1.3981, -1.3981, -1.3981] # 530nm
|
||||
- [-1.38014, -1.38014, -1.38014] # 535nm
|
||||
- [-1.36192, -1.36192, -1.36192] # 540nm
|
||||
- [-1.34305, -1.34305, -1.34305] # 545nm
|
||||
- [-1.32648, -1.32648, -1.32648] # 550nm
|
||||
- [-1.30871, -1.30871, -1.30871] # 555nm
|
||||
- [-1.29546, -1.29546, -1.29546] # 560nm
|
||||
- [-1.28805, -1.28805, -1.28805] # 565nm
|
||||
- [-1.28317, -1.28317, -1.28317] # 570nm
|
||||
- [-1.27961, -1.27961, -1.27961] # 575nm
|
||||
- [-1.28242, -1.28242, -1.28242] # 580nm
|
||||
- [-1.28885, -1.28885, -1.28885] # 585nm
|
||||
- [-1.29837, -1.29837, -1.29837] # 590nm
|
||||
- [-1.31101, -1.31101, -1.31101] # 595nm
|
||||
- [-1.32288, -1.32288, -1.32288] # 600nm
|
||||
- [-1.33805, -1.33805, -1.33805] # 605nm
|
||||
- [-1.35316, -1.35316, -1.35316] # 610nm
|
||||
- [-1.35819, -1.35819, -1.35819] # 615nm
|
||||
- [-1.34978, -1.34978, -1.34978] # 620nm
|
||||
- [-1.35875, -1.35875, -1.35875] # 625nm
|
||||
- [-1.38789, -1.38789, -1.38789] # 630nm
|
||||
- [-1.47825, -1.47825, -1.47825] # 635nm
|
||||
- [-1.66767, -1.66767, -1.66767] # 640nm
|
||||
- [-1.88331, -1.88331, -1.88331] # 645nm
|
||||
- [-2.13893, -2.13893, -2.13893] # 650nm
|
||||
- [-2.40079, -2.40079, -2.40079] # 655nm
|
||||
- [-2.66866, -2.66866, -2.66866] # 660nm
|
||||
- [-9, -9, -9] # 665nm
|
||||
- [-9, -9, -9] # 670nm
|
||||
- [-9, -9, -9] # 675nm
|
||||
- [-9, -9, -9] # 680nm
|
||||
- [-9, -9, -9] # 685nm
|
||||
- [-9, -9, -9] # 690nm
|
||||
- [-9, -9, -9] # 695nm
|
||||
- [-9, -9, -9] # 700nm
|
||||
- [-9, -9, -9] # 705nm
|
||||
- [-9, -9, -9] # 710nm
|
||||
- [-9, -9, -9] # 715nm
|
||||
- [-9, -9, -9] # 720nm
|
||||
- [-9, -9, -9] # 725nm
|
||||
- [-9, -9, -9] # 730nm
|
||||
- [-9, -9, -9] # 735nm
|
||||
- [-9, -9, -9] # 740nm
|
||||
- [-9, -9, -9] # 745nm
|
||||
- [-9, -9, -9] # 750nm
|
||||
- [-9, -9, -9] # 755nm
|
||||
- [-9, -9, -9] # 760nm
|
||||
- [-9, -9, -9] # 765nm
|
||||
- [-9, -9, -9] # 770nm
|
||||
- [-9, -9, -9] # 775nm
|
||||
- [-9, -9, -9] # 780nm
|
||||
|
||||
# Developed silver: neutral across the band.
|
||||
dye_density:
|
||||
- [0.333333, 0.333333, 0.333333] # 380nm
|
||||
- [0.333333, 0.333333, 0.333333] # 385nm
|
||||
- [0.333333, 0.333333, 0.333333] # 390nm
|
||||
- [0.333333, 0.333333, 0.333333] # 395nm
|
||||
- [0.333333, 0.333333, 0.333333] # 400nm
|
||||
- [0.333333, 0.333333, 0.333333] # 405nm
|
||||
- [0.333333, 0.333333, 0.333333] # 410nm
|
||||
- [0.333333, 0.333333, 0.333333] # 415nm
|
||||
- [0.333333, 0.333333, 0.333333] # 420nm
|
||||
- [0.333333, 0.333333, 0.333333] # 425nm
|
||||
- [0.333333, 0.333333, 0.333333] # 430nm
|
||||
- [0.333333, 0.333333, 0.333333] # 435nm
|
||||
- [0.333333, 0.333333, 0.333333] # 440nm
|
||||
- [0.333333, 0.333333, 0.333333] # 445nm
|
||||
- [0.333333, 0.333333, 0.333333] # 450nm
|
||||
- [0.333333, 0.333333, 0.333333] # 455nm
|
||||
- [0.333333, 0.333333, 0.333333] # 460nm
|
||||
- [0.333333, 0.333333, 0.333333] # 465nm
|
||||
- [0.333333, 0.333333, 0.333333] # 470nm
|
||||
- [0.333333, 0.333333, 0.333333] # 475nm
|
||||
- [0.333333, 0.333333, 0.333333] # 480nm
|
||||
- [0.333333, 0.333333, 0.333333] # 485nm
|
||||
- [0.333333, 0.333333, 0.333333] # 490nm
|
||||
- [0.333333, 0.333333, 0.333333] # 495nm
|
||||
- [0.333333, 0.333333, 0.333333] # 500nm
|
||||
- [0.333333, 0.333333, 0.333333] # 505nm
|
||||
- [0.333333, 0.333333, 0.333333] # 510nm
|
||||
- [0.333333, 0.333333, 0.333333] # 515nm
|
||||
- [0.333333, 0.333333, 0.333333] # 520nm
|
||||
- [0.333333, 0.333333, 0.333333] # 525nm
|
||||
- [0.333333, 0.333333, 0.333333] # 530nm
|
||||
- [0.333333, 0.333333, 0.333333] # 535nm
|
||||
- [0.333333, 0.333333, 0.333333] # 540nm
|
||||
- [0.333333, 0.333333, 0.333333] # 545nm
|
||||
- [0.333333, 0.333333, 0.333333] # 550nm
|
||||
- [0.333333, 0.333333, 0.333333] # 555nm
|
||||
- [0.333333, 0.333333, 0.333333] # 560nm
|
||||
- [0.333333, 0.333333, 0.333333] # 565nm
|
||||
- [0.333333, 0.333333, 0.333333] # 570nm
|
||||
- [0.333333, 0.333333, 0.333333] # 575nm
|
||||
- [0.333333, 0.333333, 0.333333] # 580nm
|
||||
- [0.333333, 0.333333, 0.333333] # 585nm
|
||||
- [0.333333, 0.333333, 0.333333] # 590nm
|
||||
- [0.333333, 0.333333, 0.333333] # 595nm
|
||||
- [0.333333, 0.333333, 0.333333] # 600nm
|
||||
- [0.333333, 0.333333, 0.333333] # 605nm
|
||||
- [0.333333, 0.333333, 0.333333] # 610nm
|
||||
- [0.333333, 0.333333, 0.333333] # 615nm
|
||||
- [0.333333, 0.333333, 0.333333] # 620nm
|
||||
- [0.333333, 0.333333, 0.333333] # 625nm
|
||||
- [0.333333, 0.333333, 0.333333] # 630nm
|
||||
- [0.333333, 0.333333, 0.333333] # 635nm
|
||||
- [0.333333, 0.333333, 0.333333] # 640nm
|
||||
- [0.333333, 0.333333, 0.333333] # 645nm
|
||||
- [0.333333, 0.333333, 0.333333] # 650nm
|
||||
- [0.333333, 0.333333, 0.333333] # 655nm
|
||||
- [0.333333, 0.333333, 0.333333] # 660nm
|
||||
- [0.333333, 0.333333, 0.333333] # 665nm
|
||||
- [0.333333, 0.333333, 0.333333] # 670nm
|
||||
- [0.333333, 0.333333, 0.333333] # 675nm
|
||||
- [0.333333, 0.333333, 0.333333] # 680nm
|
||||
- [0.333333, 0.333333, 0.333333] # 685nm
|
||||
- [0.333333, 0.333333, 0.333333] # 690nm
|
||||
- [0.333333, 0.333333, 0.333333] # 695nm
|
||||
- [0.333333, 0.333333, 0.333333] # 700nm
|
||||
- [0.333333, 0.333333, 0.333333] # 705nm
|
||||
- [0.333333, 0.333333, 0.333333] # 710nm
|
||||
- [0.333333, 0.333333, 0.333333] # 715nm
|
||||
- [0.333333, 0.333333, 0.333333] # 720nm
|
||||
- [0.333333, 0.333333, 0.333333] # 725nm
|
||||
- [0.333333, 0.333333, 0.333333] # 730nm
|
||||
- [0.333333, 0.333333, 0.333333] # 735nm
|
||||
- [0.333333, 0.333333, 0.333333] # 740nm
|
||||
- [0.333333, 0.333333, 0.333333] # 745nm
|
||||
- [0.333333, 0.333333, 0.333333] # 750nm
|
||||
- [0.333333, 0.333333, 0.333333] # 755nm
|
||||
- [0.333333, 0.333333, 0.333333] # 760nm
|
||||
- [0.333333, 0.333333, 0.333333] # 765nm
|
||||
- [0.333333, 0.333333, 0.333333] # 770nm
|
||||
- [0.333333, 0.333333, 0.333333] # 775nm
|
||||
- [0.333333, 0.333333, 0.333333] # 780nm
|
||||
|
||||
# Base plus fog. No orange mask on a black-and-white film.
|
||||
base_density: [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]
|
||||
|
||||
# The characteristic curve, parametric.
|
||||
log_exposure_min: -3
|
||||
log_exposure_max: 4
|
||||
density_curves:
|
||||
- [3.5129e-07, 3.5129e-07, 3.5129e-07]
|
||||
- [4.2083e-07, 4.2083e-07, 4.2083e-07]
|
||||
- [5.0412e-07, 5.0412e-07, 5.0412e-07]
|
||||
- [6.039e-07, 6.039e-07, 6.039e-07]
|
||||
- [7.2343e-07, 7.2343e-07, 7.2343e-07]
|
||||
- [8.6662e-07, 8.6662e-07, 8.6662e-07]
|
||||
- [1.0381e-06, 1.0381e-06, 1.0381e-06]
|
||||
- [1.2436e-06, 1.2436e-06, 1.2436e-06]
|
||||
- [1.4898e-06, 1.4898e-06, 1.4898e-06]
|
||||
- [1.7846e-06, 1.7846e-06, 1.7846e-06]
|
||||
- [2.1379e-06, 2.1379e-06, 2.1379e-06]
|
||||
- [2.561e-06, 2.561e-06, 2.561e-06]
|
||||
- [3.0679e-06, 3.0679e-06, 3.0679e-06]
|
||||
- [3.6751e-06, 3.6751e-06, 3.6751e-06]
|
||||
- [4.4025e-06, 4.4025e-06, 4.4025e-06]
|
||||
- [5.2739e-06, 5.2739e-06, 5.2739e-06]
|
||||
- [6.3177e-06, 6.3177e-06, 6.3177e-06]
|
||||
- [7.5681e-06, 7.5681e-06, 7.5681e-06]
|
||||
- [9.066e-06, 9.066e-06, 9.066e-06]
|
||||
- [1.086e-05, 1.086e-05, 1.086e-05]
|
||||
- [1.301e-05, 1.301e-05, 1.301e-05]
|
||||
- [1.5585e-05, 1.5585e-05, 1.5585e-05]
|
||||
- [1.8669e-05, 1.8669e-05, 1.8669e-05]
|
||||
- [2.2364e-05, 2.2364e-05, 2.2364e-05]
|
||||
- [2.6789e-05, 2.6789e-05, 2.6789e-05]
|
||||
- [3.2091e-05, 3.2091e-05, 3.2091e-05]
|
||||
- [3.8441e-05, 3.8441e-05, 3.8441e-05]
|
||||
- [4.6048e-05, 4.6048e-05, 4.6048e-05]
|
||||
- [5.516e-05, 5.516e-05, 5.516e-05]
|
||||
- [6.6074e-05, 6.6074e-05, 6.6074e-05]
|
||||
- [7.9146e-05, 7.9146e-05, 7.9146e-05]
|
||||
- [9.4804e-05, 9.4804e-05, 9.4804e-05]
|
||||
- [0.00011356, 0.00011356, 0.00011356]
|
||||
- [0.00013602, 0.00013602, 0.00013602]
|
||||
- [0.00016292, 0.00016292, 0.00016292]
|
||||
- [0.00019513, 0.00019513, 0.00019513]
|
||||
- [0.0002337, 0.0002337, 0.0002337]
|
||||
- [0.00027989, 0.00027989, 0.00027989]
|
||||
- [0.00033519, 0.00033519, 0.00033519]
|
||||
- [0.00040139, 0.00040139, 0.00040139]
|
||||
- [0.00048064, 0.00048064, 0.00048064]
|
||||
- [0.00057548, 0.00057548, 0.00057548]
|
||||
- [0.00068897, 0.00068897, 0.00068897]
|
||||
- [0.00082475, 0.00082475, 0.00082475]
|
||||
- [0.00098713, 0.00098713, 0.00098713]
|
||||
- [0.0011813, 0.0011813, 0.0011813]
|
||||
- [0.0014134, 0.0014134, 0.0014134]
|
||||
- [0.0016906, 0.0016906, 0.0016906]
|
||||
- [0.0020217, 0.0020217, 0.0020217]
|
||||
- [0.0024167, 0.0024167, 0.0024167]
|
||||
- [0.0028878, 0.0028878, 0.0028878]
|
||||
- [0.0034491, 0.0034491, 0.0034491]
|
||||
- [0.004117, 0.004117, 0.004117]
|
||||
- [0.004911, 0.004911, 0.004911]
|
||||
- [0.0058534, 0.0058534, 0.0058534]
|
||||
- [0.0069701, 0.0069701, 0.0069701]
|
||||
- [0.0082906, 0.0082906, 0.0082906]
|
||||
- [0.0098485, 0.0098485, 0.0098485]
|
||||
- [0.011681, 0.011681, 0.011681]
|
||||
- [0.013831, 0.013831, 0.013831]
|
||||
- [0.016344, 0.016344, 0.016344]
|
||||
- [0.019268, 0.019268, 0.019268]
|
||||
- [0.022655, 0.022655, 0.022655]
|
||||
- [0.026559, 0.026559, 0.026559]
|
||||
- [0.031032, 0.031032, 0.031032]
|
||||
- [0.036126, 0.036126, 0.036126]
|
||||
- [0.041886, 0.041886, 0.041886]
|
||||
- [0.048352, 0.048352, 0.048352]
|
||||
- [0.055556, 0.055556, 0.055556]
|
||||
- [0.063518, 0.063518, 0.063518]
|
||||
- [0.072247, 0.072247, 0.072247]
|
||||
- [0.081739, 0.081739, 0.081739]
|
||||
- [0.09198, 0.09198, 0.09198]
|
||||
- [0.10294, 0.10294, 0.10294]
|
||||
- [0.11459, 0.11459, 0.11459]
|
||||
- [0.12687, 0.12687, 0.12687]
|
||||
- [0.13975, 0.13975, 0.13975]
|
||||
- [0.15317, 0.15317, 0.15317]
|
||||
- [0.16707, 0.16707, 0.16707]
|
||||
- [0.18141, 0.18141, 0.18141]
|
||||
- [0.19613, 0.19613, 0.19613]
|
||||
- [0.21119, 0.21119, 0.21119]
|
||||
- [0.22653, 0.22653, 0.22653]
|
||||
- [0.24214, 0.24214, 0.24214]
|
||||
- [0.25796, 0.25796, 0.25796]
|
||||
- [0.27396, 0.27396, 0.27396]
|
||||
- [0.29013, 0.29013, 0.29013]
|
||||
- [0.30643, 0.30643, 0.30643]
|
||||
- [0.32284, 0.32284, 0.32284]
|
||||
- [0.33935, 0.33935, 0.33935]
|
||||
- [0.35595, 0.35595, 0.35595]
|
||||
- [0.37261, 0.37261, 0.37261]
|
||||
- [0.38933, 0.38933, 0.38933]
|
||||
- [0.4061, 0.4061, 0.4061]
|
||||
- [0.42291, 0.42291, 0.42291]
|
||||
- [0.43975, 0.43975, 0.43975]
|
||||
- [0.45663, 0.45663, 0.45663]
|
||||
- [0.47352, 0.47352, 0.47352]
|
||||
- [0.49044, 0.49044, 0.49044]
|
||||
- [0.50737, 0.50737, 0.50737]
|
||||
- [0.52432, 0.52432, 0.52432]
|
||||
- [0.54128, 0.54128, 0.54128]
|
||||
- [0.55825, 0.55825, 0.55825]
|
||||
- [0.57523, 0.57523, 0.57523]
|
||||
- [0.59222, 0.59222, 0.59222]
|
||||
- [0.60921, 0.60921, 0.60921]
|
||||
- [0.6262, 0.6262, 0.6262]
|
||||
- [0.6432, 0.6432, 0.6432]
|
||||
- [0.6602, 0.6602, 0.6602]
|
||||
- [0.67721, 0.67721, 0.67721]
|
||||
- [0.69422, 0.69422, 0.69422]
|
||||
- [0.71123, 0.71123, 0.71123]
|
||||
- [0.72824, 0.72824, 0.72824]
|
||||
- [0.74525, 0.74525, 0.74525]
|
||||
- [0.76226, 0.76226, 0.76226]
|
||||
- [0.77928, 0.77928, 0.77928]
|
||||
- [0.79629, 0.79629, 0.79629]
|
||||
- [0.81331, 0.81331, 0.81331]
|
||||
- [0.83033, 0.83033, 0.83033]
|
||||
- [0.84734, 0.84734, 0.84734]
|
||||
- [0.86436, 0.86436, 0.86436]
|
||||
- [0.88138, 0.88138, 0.88138]
|
||||
- [0.8984, 0.8984, 0.8984]
|
||||
- [0.91542, 0.91542, 0.91542]
|
||||
- [0.93244, 0.93244, 0.93244]
|
||||
- [0.94945, 0.94945, 0.94945]
|
||||
- [0.96647, 0.96647, 0.96647]
|
||||
- [0.98349, 0.98349, 0.98349]
|
||||
- [1.0005, 1.0005, 1.0005]
|
||||
- [1.0175, 1.0175, 1.0175]
|
||||
- [1.0345, 1.0345, 1.0345]
|
||||
- [1.0516, 1.0516, 1.0516]
|
||||
- [1.0686, 1.0686, 1.0686]
|
||||
- [1.0856, 1.0856, 1.0856]
|
||||
- [1.1026, 1.1026, 1.1026]
|
||||
- [1.1196, 1.1196, 1.1196]
|
||||
- [1.1367, 1.1367, 1.1367]
|
||||
- [1.1537, 1.1537, 1.1537]
|
||||
- [1.1707, 1.1707, 1.1707]
|
||||
- [1.1877, 1.1877, 1.1877]
|
||||
- [1.2047, 1.2047, 1.2047]
|
||||
- [1.2218, 1.2218, 1.2218]
|
||||
- [1.2388, 1.2388, 1.2388]
|
||||
- [1.2558, 1.2558, 1.2558]
|
||||
- [1.2728, 1.2728, 1.2728]
|
||||
- [1.2898, 1.2898, 1.2898]
|
||||
- [1.3068, 1.3068, 1.3068]
|
||||
- [1.3239, 1.3239, 1.3239]
|
||||
- [1.3409, 1.3409, 1.3409]
|
||||
- [1.3579, 1.3579, 1.3579]
|
||||
- [1.3749, 1.3749, 1.3749]
|
||||
- [1.3919, 1.3919, 1.3919]
|
||||
- [1.4089, 1.4089, 1.4089]
|
||||
- [1.426, 1.426, 1.426]
|
||||
- [1.443, 1.443, 1.443]
|
||||
- [1.46, 1.46, 1.46]
|
||||
- [1.477, 1.477, 1.477]
|
||||
- [1.494, 1.494, 1.494]
|
||||
- [1.511, 1.511, 1.511]
|
||||
- [1.528, 1.528, 1.528]
|
||||
- [1.545, 1.545, 1.545]
|
||||
- [1.562, 1.562, 1.562]
|
||||
- [1.579, 1.579, 1.579]
|
||||
- [1.596, 1.596, 1.596]
|
||||
- [1.613, 1.613, 1.613]
|
||||
- [1.63, 1.63, 1.63]
|
||||
- [1.647, 1.647, 1.647]
|
||||
- [1.664, 1.664, 1.664]
|
||||
- [1.681, 1.681, 1.681]
|
||||
- [1.6979, 1.6979, 1.6979]
|
||||
- [1.7149, 1.7149, 1.7149]
|
||||
- [1.7319, 1.7319, 1.7319]
|
||||
- [1.7488, 1.7488, 1.7488]
|
||||
- [1.7657, 1.7657, 1.7657]
|
||||
- [1.7827, 1.7827, 1.7827]
|
||||
- [1.7996, 1.7996, 1.7996]
|
||||
- [1.8165, 1.8165, 1.8165]
|
||||
- [1.8333, 1.8333, 1.8333]
|
||||
- [1.8502, 1.8502, 1.8502]
|
||||
- [1.867, 1.867, 1.867]
|
||||
- [1.8838, 1.8838, 1.8838]
|
||||
- [1.9006, 1.9006, 1.9006]
|
||||
- [1.9173, 1.9173, 1.9173]
|
||||
- [1.934, 1.934, 1.934]
|
||||
- [1.9506, 1.9506, 1.9506]
|
||||
- [1.9672, 1.9672, 1.9672]
|
||||
- [1.9837, 1.9837, 1.9837]
|
||||
- [2.0001, 2.0001, 2.0001]
|
||||
- [2.0165, 2.0165, 2.0165]
|
||||
- [2.0327, 2.0327, 2.0327]
|
||||
- [2.0489, 2.0489, 2.0489]
|
||||
- [2.0649, 2.0649, 2.0649]
|
||||
- [2.0807, 2.0807, 2.0807]
|
||||
- [2.0965, 2.0965, 2.0965]
|
||||
- [2.112, 2.112, 2.112]
|
||||
- [2.1273, 2.1273, 2.1273]
|
||||
- [2.1424, 2.1424, 2.1424]
|
||||
- [2.1573, 2.1573, 2.1573]
|
||||
- [2.1719, 2.1719, 2.1719]
|
||||
- [2.1861, 2.1861, 2.1861]
|
||||
- [2.2001, 2.2001, 2.2001]
|
||||
- [2.2136, 2.2136, 2.2136]
|
||||
- [2.2268, 2.2268, 2.2268]
|
||||
- [2.2395, 2.2395, 2.2395]
|
||||
- [2.2518, 2.2518, 2.2518]
|
||||
- [2.2635, 2.2635, 2.2635]
|
||||
- [2.2748, 2.2748, 2.2748]
|
||||
- [2.2855, 2.2855, 2.2855]
|
||||
- [2.2956, 2.2956, 2.2956]
|
||||
- [2.3051, 2.3051, 2.3051]
|
||||
- [2.3141, 2.3141, 2.3141]
|
||||
- [2.3224, 2.3224, 2.3224]
|
||||
- [2.3302, 2.3302, 2.3302]
|
||||
- [2.3373, 2.3373, 2.3373]
|
||||
- [2.3439, 2.3439, 2.3439]
|
||||
- [2.3499, 2.3499, 2.3499]
|
||||
- [2.3554, 2.3554, 2.3554]
|
||||
- [2.3604, 2.3604, 2.3604]
|
||||
- [2.3649, 2.3649, 2.3649]
|
||||
- [2.369, 2.369, 2.369]
|
||||
- [2.3726, 2.3726, 2.3726]
|
||||
- [2.3759, 2.3759, 2.3759]
|
||||
- [2.3788, 2.3788, 2.3788]
|
||||
- [2.3814, 2.3814, 2.3814]
|
||||
- [2.3836, 2.3836, 2.3836]
|
||||
- [2.3857, 2.3857, 2.3857]
|
||||
- [2.3875, 2.3875, 2.3875]
|
||||
- [2.389, 2.389, 2.389]
|
||||
- [2.3904, 2.3904, 2.3904]
|
||||
- [2.3916, 2.3916, 2.3916]
|
||||
- [2.3927, 2.3927, 2.3927]
|
||||
- [2.3936, 2.3936, 2.3936]
|
||||
- [2.3944, 2.3944, 2.3944]
|
||||
- [2.3952, 2.3952, 2.3952]
|
||||
- [2.3958, 2.3958, 2.3958]
|
||||
- [2.3963, 2.3963, 2.3963]
|
||||
- [2.3968, 2.3968, 2.3968]
|
||||
- [2.3972, 2.3972, 2.3972]
|
||||
- [2.3976, 2.3976, 2.3976]
|
||||
- [2.3979, 2.3979, 2.3979]
|
||||
- [2.3982, 2.3982, 2.3982]
|
||||
- [2.3984, 2.3984, 2.3984]
|
||||
- [2.3986, 2.3986, 2.3986]
|
||||
- [2.3988, 2.3988, 2.3988]
|
||||
- [2.3989, 2.3989, 2.3989]
|
||||
- [2.3991, 2.3991, 2.3991]
|
||||
- [2.3992, 2.3992, 2.3992]
|
||||
- [2.3993, 2.3993, 2.3993]
|
||||
- [2.3994, 2.3994, 2.3994]
|
||||
- [2.3995, 2.3995, 2.3995]
|
||||
- [2.3995, 2.3995, 2.3995]
|
||||
- [2.3996, 2.3996, 2.3996]
|
||||
- [2.3997, 2.3997, 2.3997]
|
||||
- [2.3997, 2.3997, 2.3997]
|
||||
- [2.3997, 2.3997, 2.3997]
|
||||
- [2.3998, 2.3998, 2.3998]
|
||||
@@ -1,460 +0,0 @@
|
||||
# Generated by tools/film-profiles/convert.py.
|
||||
#
|
||||
# *** CONSTRUCTED, NOT MEASURED. ***
|
||||
#
|
||||
# Ilford publish this film's spectral sensitivity and characteristic
|
||||
# curve as graphs, and no granularity figure at all, so this profile is
|
||||
# built rather than extracted. Its speed is the published ISO rating,
|
||||
# and its contrast is ISO 6:1993's normal development (average gradient
|
||||
# 0.62). Its spectral response is borrowed from Kodak Double-X, a
|
||||
# *measured* panchromatic negative, shifted by the speed difference.
|
||||
# Its granularity is an estimate, ordered against the other films here.
|
||||
#
|
||||
# So it renders as a film of this speed and contrast, not as a
|
||||
# measurement of this emulsion. Replace it outright if anyone digitises
|
||||
# the real curves.
|
||||
|
||||
version: 'constructed-1'
|
||||
stock: ilford_delta_400
|
||||
name: 'Ilford Delta 400'
|
||||
kind: negative # negative | positive
|
||||
support: film # film | paper
|
||||
stage: filming # filming | printing
|
||||
monochrome: true
|
||||
target_print: kodak_2302
|
||||
reference_illuminant: D55
|
||||
viewing_illuminant: D50
|
||||
|
||||
rms_granularity: [9, 9, 9]
|
||||
|
||||
# log10 spectral sensitivity per layer, 380-780nm at 5nm.
|
||||
log_sensitivity:
|
||||
- [-9, -9, -9] # 380nm
|
||||
- [-9, -9, -9] # 385nm
|
||||
- [-9, -9, -9] # 390nm
|
||||
- [-9, -9, -9] # 395nm
|
||||
- [-9, -9, -9] # 400nm
|
||||
- [-9, -9, -9] # 405nm
|
||||
- [-9, -9, -9] # 410nm
|
||||
- [-9, -9, -9] # 415nm
|
||||
- [-9, -9, -9] # 420nm
|
||||
- [-0.513098, -0.513098, -0.513098] # 425nm
|
||||
- [-0.51284, -0.51284, -0.51284] # 430nm
|
||||
- [-0.51284, -0.51284, -0.51284] # 435nm
|
||||
- [-0.51319, -0.51319, -0.51319] # 440nm
|
||||
- [-0.522893, -0.522893, -0.522893] # 445nm
|
||||
- [-0.53562, -0.53562, -0.53562] # 450nm
|
||||
- [-0.552412, -0.552412, -0.552412] # 455nm
|
||||
- [-0.571665, -0.571665, -0.571665] # 460nm
|
||||
- [-0.593122, -0.593122, -0.593122] # 465nm
|
||||
- [-0.611553, -0.611553, -0.611553] # 470nm
|
||||
- [-0.637438, -0.637438, -0.637438] # 475nm
|
||||
- [-0.665217, -0.665217, -0.665217] # 480nm
|
||||
- [-0.703161, -0.703161, -0.703161] # 485nm
|
||||
- [-0.742909, -0.742909, -0.742909] # 490nm
|
||||
- [-0.782594, -0.782594, -0.782594] # 495nm
|
||||
- [-0.815861, -0.815861, -0.815861] # 500nm
|
||||
- [-0.831143, -0.831143, -0.831143] # 505nm
|
||||
- [-0.834309, -0.834309, -0.834309] # 510nm
|
||||
- [-0.833599, -0.833599, -0.833599] # 515nm
|
||||
- [-0.823829, -0.823829, -0.823829] # 520nm
|
||||
- [-0.810809, -0.810809, -0.810809] # 525nm
|
||||
- [-0.796036, -0.796036, -0.796036] # 530nm
|
||||
- [-0.778081, -0.778081, -0.778081] # 535nm
|
||||
- [-0.759864, -0.759864, -0.759864] # 540nm
|
||||
- [-0.740987, -0.740987, -0.740987] # 545nm
|
||||
- [-0.724425, -0.724425, -0.724425] # 550nm
|
||||
- [-0.706653, -0.706653, -0.706653] # 555nm
|
||||
- [-0.693404, -0.693404, -0.693404] # 560nm
|
||||
- [-0.685993, -0.685993, -0.685993] # 565nm
|
||||
- [-0.68111, -0.68111, -0.68111] # 570nm
|
||||
- [-0.677547, -0.677547, -0.677547] # 575nm
|
||||
- [-0.680358, -0.680358, -0.680358] # 580nm
|
||||
- [-0.686794, -0.686794, -0.686794] # 585nm
|
||||
- [-0.696308, -0.696308, -0.696308] # 590nm
|
||||
- [-0.708955, -0.708955, -0.708955] # 595nm
|
||||
- [-0.720822, -0.720822, -0.720822] # 600nm
|
||||
- [-0.735995, -0.735995, -0.735995] # 605nm
|
||||
- [-0.751102, -0.751102, -0.751102] # 610nm
|
||||
- [-0.75613, -0.75613, -0.75613] # 615nm
|
||||
- [-0.747716, -0.747716, -0.747716] # 620nm
|
||||
- [-0.756689, -0.756689, -0.756689] # 625nm
|
||||
- [-0.785834, -0.785834, -0.785834] # 630nm
|
||||
- [-0.876194, -0.876194, -0.876194] # 635nm
|
||||
- [-1.06561, -1.06561, -1.06561] # 640nm
|
||||
- [-1.28125, -1.28125, -1.28125] # 645nm
|
||||
- [-1.53687, -1.53687, -1.53687] # 650nm
|
||||
- [-1.79873, -1.79873, -1.79873] # 655nm
|
||||
- [-2.0666, -2.0666, -2.0666] # 660nm
|
||||
- [-9, -9, -9] # 665nm
|
||||
- [-9, -9, -9] # 670nm
|
||||
- [-9, -9, -9] # 675nm
|
||||
- [-9, -9, -9] # 680nm
|
||||
- [-9, -9, -9] # 685nm
|
||||
- [-9, -9, -9] # 690nm
|
||||
- [-9, -9, -9] # 695nm
|
||||
- [-9, -9, -9] # 700nm
|
||||
- [-9, -9, -9] # 705nm
|
||||
- [-9, -9, -9] # 710nm
|
||||
- [-9, -9, -9] # 715nm
|
||||
- [-9, -9, -9] # 720nm
|
||||
- [-9, -9, -9] # 725nm
|
||||
- [-9, -9, -9] # 730nm
|
||||
- [-9, -9, -9] # 735nm
|
||||
- [-9, -9, -9] # 740nm
|
||||
- [-9, -9, -9] # 745nm
|
||||
- [-9, -9, -9] # 750nm
|
||||
- [-9, -9, -9] # 755nm
|
||||
- [-9, -9, -9] # 760nm
|
||||
- [-9, -9, -9] # 765nm
|
||||
- [-9, -9, -9] # 770nm
|
||||
- [-9, -9, -9] # 775nm
|
||||
- [-9, -9, -9] # 780nm
|
||||
|
||||
# Developed silver: neutral across the band.
|
||||
dye_density:
|
||||
- [0.333333, 0.333333, 0.333333] # 380nm
|
||||
- [0.333333, 0.333333, 0.333333] # 385nm
|
||||
- [0.333333, 0.333333, 0.333333] # 390nm
|
||||
- [0.333333, 0.333333, 0.333333] # 395nm
|
||||
- [0.333333, 0.333333, 0.333333] # 400nm
|
||||
- [0.333333, 0.333333, 0.333333] # 405nm
|
||||
- [0.333333, 0.333333, 0.333333] # 410nm
|
||||
- [0.333333, 0.333333, 0.333333] # 415nm
|
||||
- [0.333333, 0.333333, 0.333333] # 420nm
|
||||
- [0.333333, 0.333333, 0.333333] # 425nm
|
||||
- [0.333333, 0.333333, 0.333333] # 430nm
|
||||
- [0.333333, 0.333333, 0.333333] # 435nm
|
||||
- [0.333333, 0.333333, 0.333333] # 440nm
|
||||
- [0.333333, 0.333333, 0.333333] # 445nm
|
||||
- [0.333333, 0.333333, 0.333333] # 450nm
|
||||
- [0.333333, 0.333333, 0.333333] # 455nm
|
||||
- [0.333333, 0.333333, 0.333333] # 460nm
|
||||
- [0.333333, 0.333333, 0.333333] # 465nm
|
||||
- [0.333333, 0.333333, 0.333333] # 470nm
|
||||
- [0.333333, 0.333333, 0.333333] # 475nm
|
||||
- [0.333333, 0.333333, 0.333333] # 480nm
|
||||
- [0.333333, 0.333333, 0.333333] # 485nm
|
||||
- [0.333333, 0.333333, 0.333333] # 490nm
|
||||
- [0.333333, 0.333333, 0.333333] # 495nm
|
||||
- [0.333333, 0.333333, 0.333333] # 500nm
|
||||
- [0.333333, 0.333333, 0.333333] # 505nm
|
||||
- [0.333333, 0.333333, 0.333333] # 510nm
|
||||
- [0.333333, 0.333333, 0.333333] # 515nm
|
||||
- [0.333333, 0.333333, 0.333333] # 520nm
|
||||
- [0.333333, 0.333333, 0.333333] # 525nm
|
||||
- [0.333333, 0.333333, 0.333333] # 530nm
|
||||
- [0.333333, 0.333333, 0.333333] # 535nm
|
||||
- [0.333333, 0.333333, 0.333333] # 540nm
|
||||
- [0.333333, 0.333333, 0.333333] # 545nm
|
||||
- [0.333333, 0.333333, 0.333333] # 550nm
|
||||
- [0.333333, 0.333333, 0.333333] # 555nm
|
||||
- [0.333333, 0.333333, 0.333333] # 560nm
|
||||
- [0.333333, 0.333333, 0.333333] # 565nm
|
||||
- [0.333333, 0.333333, 0.333333] # 570nm
|
||||
- [0.333333, 0.333333, 0.333333] # 575nm
|
||||
- [0.333333, 0.333333, 0.333333] # 580nm
|
||||
- [0.333333, 0.333333, 0.333333] # 585nm
|
||||
- [0.333333, 0.333333, 0.333333] # 590nm
|
||||
- [0.333333, 0.333333, 0.333333] # 595nm
|
||||
- [0.333333, 0.333333, 0.333333] # 600nm
|
||||
- [0.333333, 0.333333, 0.333333] # 605nm
|
||||
- [0.333333, 0.333333, 0.333333] # 610nm
|
||||
- [0.333333, 0.333333, 0.333333] # 615nm
|
||||
- [0.333333, 0.333333, 0.333333] # 620nm
|
||||
- [0.333333, 0.333333, 0.333333] # 625nm
|
||||
- [0.333333, 0.333333, 0.333333] # 630nm
|
||||
- [0.333333, 0.333333, 0.333333] # 635nm
|
||||
- [0.333333, 0.333333, 0.333333] # 640nm
|
||||
- [0.333333, 0.333333, 0.333333] # 645nm
|
||||
- [0.333333, 0.333333, 0.333333] # 650nm
|
||||
- [0.333333, 0.333333, 0.333333] # 655nm
|
||||
- [0.333333, 0.333333, 0.333333] # 660nm
|
||||
- [0.333333, 0.333333, 0.333333] # 665nm
|
||||
- [0.333333, 0.333333, 0.333333] # 670nm
|
||||
- [0.333333, 0.333333, 0.333333] # 675nm
|
||||
- [0.333333, 0.333333, 0.333333] # 680nm
|
||||
- [0.333333, 0.333333, 0.333333] # 685nm
|
||||
- [0.333333, 0.333333, 0.333333] # 690nm
|
||||
- [0.333333, 0.333333, 0.333333] # 695nm
|
||||
- [0.333333, 0.333333, 0.333333] # 700nm
|
||||
- [0.333333, 0.333333, 0.333333] # 705nm
|
||||
- [0.333333, 0.333333, 0.333333] # 710nm
|
||||
- [0.333333, 0.333333, 0.333333] # 715nm
|
||||
- [0.333333, 0.333333, 0.333333] # 720nm
|
||||
- [0.333333, 0.333333, 0.333333] # 725nm
|
||||
- [0.333333, 0.333333, 0.333333] # 730nm
|
||||
- [0.333333, 0.333333, 0.333333] # 735nm
|
||||
- [0.333333, 0.333333, 0.333333] # 740nm
|
||||
- [0.333333, 0.333333, 0.333333] # 745nm
|
||||
- [0.333333, 0.333333, 0.333333] # 750nm
|
||||
- [0.333333, 0.333333, 0.333333] # 755nm
|
||||
- [0.333333, 0.333333, 0.333333] # 760nm
|
||||
- [0.333333, 0.333333, 0.333333] # 765nm
|
||||
- [0.333333, 0.333333, 0.333333] # 770nm
|
||||
- [0.333333, 0.333333, 0.333333] # 775nm
|
||||
- [0.333333, 0.333333, 0.333333] # 780nm
|
||||
|
||||
# Base plus fog. No orange mask on a black-and-white film.
|
||||
base_density: [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]
|
||||
|
||||
# The characteristic curve, parametric.
|
||||
log_exposure_min: -3
|
||||
log_exposure_max: 4
|
||||
density_curves:
|
||||
- [3.5129e-07, 3.5129e-07, 3.5129e-07]
|
||||
- [4.2083e-07, 4.2083e-07, 4.2083e-07]
|
||||
- [5.0412e-07, 5.0412e-07, 5.0412e-07]
|
||||
- [6.039e-07, 6.039e-07, 6.039e-07]
|
||||
- [7.2343e-07, 7.2343e-07, 7.2343e-07]
|
||||
- [8.6662e-07, 8.6662e-07, 8.6662e-07]
|
||||
- [1.0381e-06, 1.0381e-06, 1.0381e-06]
|
||||
- [1.2436e-06, 1.2436e-06, 1.2436e-06]
|
||||
- [1.4898e-06, 1.4898e-06, 1.4898e-06]
|
||||
- [1.7846e-06, 1.7846e-06, 1.7846e-06]
|
||||
- [2.1379e-06, 2.1379e-06, 2.1379e-06]
|
||||
- [2.561e-06, 2.561e-06, 2.561e-06]
|
||||
- [3.0679e-06, 3.0679e-06, 3.0679e-06]
|
||||
- [3.6751e-06, 3.6751e-06, 3.6751e-06]
|
||||
- [4.4025e-06, 4.4025e-06, 4.4025e-06]
|
||||
- [5.2739e-06, 5.2739e-06, 5.2739e-06]
|
||||
- [6.3177e-06, 6.3177e-06, 6.3177e-06]
|
||||
- [7.5681e-06, 7.5681e-06, 7.5681e-06]
|
||||
- [9.066e-06, 9.066e-06, 9.066e-06]
|
||||
- [1.086e-05, 1.086e-05, 1.086e-05]
|
||||
- [1.301e-05, 1.301e-05, 1.301e-05]
|
||||
- [1.5585e-05, 1.5585e-05, 1.5585e-05]
|
||||
- [1.8669e-05, 1.8669e-05, 1.8669e-05]
|
||||
- [2.2364e-05, 2.2364e-05, 2.2364e-05]
|
||||
- [2.6789e-05, 2.6789e-05, 2.6789e-05]
|
||||
- [3.2091e-05, 3.2091e-05, 3.2091e-05]
|
||||
- [3.8441e-05, 3.8441e-05, 3.8441e-05]
|
||||
- [4.6048e-05, 4.6048e-05, 4.6048e-05]
|
||||
- [5.516e-05, 5.516e-05, 5.516e-05]
|
||||
- [6.6074e-05, 6.6074e-05, 6.6074e-05]
|
||||
- [7.9146e-05, 7.9146e-05, 7.9146e-05]
|
||||
- [9.4804e-05, 9.4804e-05, 9.4804e-05]
|
||||
- [0.00011356, 0.00011356, 0.00011356]
|
||||
- [0.00013602, 0.00013602, 0.00013602]
|
||||
- [0.00016292, 0.00016292, 0.00016292]
|
||||
- [0.00019513, 0.00019513, 0.00019513]
|
||||
- [0.0002337, 0.0002337, 0.0002337]
|
||||
- [0.00027989, 0.00027989, 0.00027989]
|
||||
- [0.00033519, 0.00033519, 0.00033519]
|
||||
- [0.00040139, 0.00040139, 0.00040139]
|
||||
- [0.00048064, 0.00048064, 0.00048064]
|
||||
- [0.00057548, 0.00057548, 0.00057548]
|
||||
- [0.00068897, 0.00068897, 0.00068897]
|
||||
- [0.00082475, 0.00082475, 0.00082475]
|
||||
- [0.00098713, 0.00098713, 0.00098713]
|
||||
- [0.0011813, 0.0011813, 0.0011813]
|
||||
- [0.0014134, 0.0014134, 0.0014134]
|
||||
- [0.0016906, 0.0016906, 0.0016906]
|
||||
- [0.0020217, 0.0020217, 0.0020217]
|
||||
- [0.0024167, 0.0024167, 0.0024167]
|
||||
- [0.0028878, 0.0028878, 0.0028878]
|
||||
- [0.0034491, 0.0034491, 0.0034491]
|
||||
- [0.004117, 0.004117, 0.004117]
|
||||
- [0.004911, 0.004911, 0.004911]
|
||||
- [0.0058534, 0.0058534, 0.0058534]
|
||||
- [0.0069701, 0.0069701, 0.0069701]
|
||||
- [0.0082906, 0.0082906, 0.0082906]
|
||||
- [0.0098485, 0.0098485, 0.0098485]
|
||||
- [0.011681, 0.011681, 0.011681]
|
||||
- [0.013831, 0.013831, 0.013831]
|
||||
- [0.016344, 0.016344, 0.016344]
|
||||
- [0.019268, 0.019268, 0.019268]
|
||||
- [0.022655, 0.022655, 0.022655]
|
||||
- [0.026559, 0.026559, 0.026559]
|
||||
- [0.031032, 0.031032, 0.031032]
|
||||
- [0.036126, 0.036126, 0.036126]
|
||||
- [0.041886, 0.041886, 0.041886]
|
||||
- [0.048352, 0.048352, 0.048352]
|
||||
- [0.055556, 0.055556, 0.055556]
|
||||
- [0.063518, 0.063518, 0.063518]
|
||||
- [0.072247, 0.072247, 0.072247]
|
||||
- [0.081739, 0.081739, 0.081739]
|
||||
- [0.09198, 0.09198, 0.09198]
|
||||
- [0.10294, 0.10294, 0.10294]
|
||||
- [0.11459, 0.11459, 0.11459]
|
||||
- [0.12687, 0.12687, 0.12687]
|
||||
- [0.13975, 0.13975, 0.13975]
|
||||
- [0.15317, 0.15317, 0.15317]
|
||||
- [0.16707, 0.16707, 0.16707]
|
||||
- [0.18141, 0.18141, 0.18141]
|
||||
- [0.19613, 0.19613, 0.19613]
|
||||
- [0.21119, 0.21119, 0.21119]
|
||||
- [0.22653, 0.22653, 0.22653]
|
||||
- [0.24214, 0.24214, 0.24214]
|
||||
- [0.25796, 0.25796, 0.25796]
|
||||
- [0.27396, 0.27396, 0.27396]
|
||||
- [0.29013, 0.29013, 0.29013]
|
||||
- [0.30643, 0.30643, 0.30643]
|
||||
- [0.32284, 0.32284, 0.32284]
|
||||
- [0.33935, 0.33935, 0.33935]
|
||||
- [0.35595, 0.35595, 0.35595]
|
||||
- [0.37261, 0.37261, 0.37261]
|
||||
- [0.38933, 0.38933, 0.38933]
|
||||
- [0.4061, 0.4061, 0.4061]
|
||||
- [0.42291, 0.42291, 0.42291]
|
||||
- [0.43975, 0.43975, 0.43975]
|
||||
- [0.45663, 0.45663, 0.45663]
|
||||
- [0.47352, 0.47352, 0.47352]
|
||||
- [0.49044, 0.49044, 0.49044]
|
||||
- [0.50737, 0.50737, 0.50737]
|
||||
- [0.52432, 0.52432, 0.52432]
|
||||
- [0.54128, 0.54128, 0.54128]
|
||||
- [0.55825, 0.55825, 0.55825]
|
||||
- [0.57523, 0.57523, 0.57523]
|
||||
- [0.59222, 0.59222, 0.59222]
|
||||
- [0.60921, 0.60921, 0.60921]
|
||||
- [0.6262, 0.6262, 0.6262]
|
||||
- [0.6432, 0.6432, 0.6432]
|
||||
- [0.6602, 0.6602, 0.6602]
|
||||
- [0.67721, 0.67721, 0.67721]
|
||||
- [0.69422, 0.69422, 0.69422]
|
||||
- [0.71123, 0.71123, 0.71123]
|
||||
- [0.72824, 0.72824, 0.72824]
|
||||
- [0.74525, 0.74525, 0.74525]
|
||||
- [0.76226, 0.76226, 0.76226]
|
||||
- [0.77928, 0.77928, 0.77928]
|
||||
- [0.79629, 0.79629, 0.79629]
|
||||
- [0.81331, 0.81331, 0.81331]
|
||||
- [0.83033, 0.83033, 0.83033]
|
||||
- [0.84734, 0.84734, 0.84734]
|
||||
- [0.86436, 0.86436, 0.86436]
|
||||
- [0.88138, 0.88138, 0.88138]
|
||||
- [0.8984, 0.8984, 0.8984]
|
||||
- [0.91542, 0.91542, 0.91542]
|
||||
- [0.93244, 0.93244, 0.93244]
|
||||
- [0.94945, 0.94945, 0.94945]
|
||||
- [0.96647, 0.96647, 0.96647]
|
||||
- [0.98349, 0.98349, 0.98349]
|
||||
- [1.0005, 1.0005, 1.0005]
|
||||
- [1.0175, 1.0175, 1.0175]
|
||||
- [1.0345, 1.0345, 1.0345]
|
||||
- [1.0516, 1.0516, 1.0516]
|
||||
- [1.0686, 1.0686, 1.0686]
|
||||
- [1.0856, 1.0856, 1.0856]
|
||||
- [1.1026, 1.1026, 1.1026]
|
||||
- [1.1196, 1.1196, 1.1196]
|
||||
- [1.1367, 1.1367, 1.1367]
|
||||
- [1.1537, 1.1537, 1.1537]
|
||||
- [1.1707, 1.1707, 1.1707]
|
||||
- [1.1877, 1.1877, 1.1877]
|
||||
- [1.2047, 1.2047, 1.2047]
|
||||
- [1.2218, 1.2218, 1.2218]
|
||||
- [1.2388, 1.2388, 1.2388]
|
||||
- [1.2558, 1.2558, 1.2558]
|
||||
- [1.2728, 1.2728, 1.2728]
|
||||
- [1.2898, 1.2898, 1.2898]
|
||||
- [1.3068, 1.3068, 1.3068]
|
||||
- [1.3239, 1.3239, 1.3239]
|
||||
- [1.3409, 1.3409, 1.3409]
|
||||
- [1.3579, 1.3579, 1.3579]
|
||||
- [1.3749, 1.3749, 1.3749]
|
||||
- [1.3919, 1.3919, 1.3919]
|
||||
- [1.4089, 1.4089, 1.4089]
|
||||
- [1.426, 1.426, 1.426]
|
||||
- [1.443, 1.443, 1.443]
|
||||
- [1.46, 1.46, 1.46]
|
||||
- [1.477, 1.477, 1.477]
|
||||
- [1.494, 1.494, 1.494]
|
||||
- [1.511, 1.511, 1.511]
|
||||
- [1.528, 1.528, 1.528]
|
||||
- [1.545, 1.545, 1.545]
|
||||
- [1.562, 1.562, 1.562]
|
||||
- [1.579, 1.579, 1.579]
|
||||
- [1.596, 1.596, 1.596]
|
||||
- [1.613, 1.613, 1.613]
|
||||
- [1.63, 1.63, 1.63]
|
||||
- [1.647, 1.647, 1.647]
|
||||
- [1.664, 1.664, 1.664]
|
||||
- [1.681, 1.681, 1.681]
|
||||
- [1.6979, 1.6979, 1.6979]
|
||||
- [1.7149, 1.7149, 1.7149]
|
||||
- [1.7319, 1.7319, 1.7319]
|
||||
- [1.7488, 1.7488, 1.7488]
|
||||
- [1.7657, 1.7657, 1.7657]
|
||||
- [1.7827, 1.7827, 1.7827]
|
||||
- [1.7996, 1.7996, 1.7996]
|
||||
- [1.8165, 1.8165, 1.8165]
|
||||
- [1.8333, 1.8333, 1.8333]
|
||||
- [1.8502, 1.8502, 1.8502]
|
||||
- [1.867, 1.867, 1.867]
|
||||
- [1.8838, 1.8838, 1.8838]
|
||||
- [1.9006, 1.9006, 1.9006]
|
||||
- [1.9173, 1.9173, 1.9173]
|
||||
- [1.934, 1.934, 1.934]
|
||||
- [1.9506, 1.9506, 1.9506]
|
||||
- [1.9672, 1.9672, 1.9672]
|
||||
- [1.9837, 1.9837, 1.9837]
|
||||
- [2.0001, 2.0001, 2.0001]
|
||||
- [2.0165, 2.0165, 2.0165]
|
||||
- [2.0327, 2.0327, 2.0327]
|
||||
- [2.0489, 2.0489, 2.0489]
|
||||
- [2.0649, 2.0649, 2.0649]
|
||||
- [2.0807, 2.0807, 2.0807]
|
||||
- [2.0965, 2.0965, 2.0965]
|
||||
- [2.112, 2.112, 2.112]
|
||||
- [2.1273, 2.1273, 2.1273]
|
||||
- [2.1424, 2.1424, 2.1424]
|
||||
- [2.1573, 2.1573, 2.1573]
|
||||
- [2.1719, 2.1719, 2.1719]
|
||||
- [2.1861, 2.1861, 2.1861]
|
||||
- [2.2001, 2.2001, 2.2001]
|
||||
- [2.2136, 2.2136, 2.2136]
|
||||
- [2.2268, 2.2268, 2.2268]
|
||||
- [2.2395, 2.2395, 2.2395]
|
||||
- [2.2518, 2.2518, 2.2518]
|
||||
- [2.2635, 2.2635, 2.2635]
|
||||
- [2.2748, 2.2748, 2.2748]
|
||||
- [2.2855, 2.2855, 2.2855]
|
||||
- [2.2956, 2.2956, 2.2956]
|
||||
- [2.3051, 2.3051, 2.3051]
|
||||
- [2.3141, 2.3141, 2.3141]
|
||||
- [2.3224, 2.3224, 2.3224]
|
||||
- [2.3302, 2.3302, 2.3302]
|
||||
- [2.3373, 2.3373, 2.3373]
|
||||
- [2.3439, 2.3439, 2.3439]
|
||||
- [2.3499, 2.3499, 2.3499]
|
||||
- [2.3554, 2.3554, 2.3554]
|
||||
- [2.3604, 2.3604, 2.3604]
|
||||
- [2.3649, 2.3649, 2.3649]
|
||||
- [2.369, 2.369, 2.369]
|
||||
- [2.3726, 2.3726, 2.3726]
|
||||
- [2.3759, 2.3759, 2.3759]
|
||||
- [2.3788, 2.3788, 2.3788]
|
||||
- [2.3814, 2.3814, 2.3814]
|
||||
- [2.3836, 2.3836, 2.3836]
|
||||
- [2.3857, 2.3857, 2.3857]
|
||||
- [2.3875, 2.3875, 2.3875]
|
||||
- [2.389, 2.389, 2.389]
|
||||
- [2.3904, 2.3904, 2.3904]
|
||||
- [2.3916, 2.3916, 2.3916]
|
||||
- [2.3927, 2.3927, 2.3927]
|
||||
- [2.3936, 2.3936, 2.3936]
|
||||
- [2.3944, 2.3944, 2.3944]
|
||||
- [2.3952, 2.3952, 2.3952]
|
||||
- [2.3958, 2.3958, 2.3958]
|
||||
- [2.3963, 2.3963, 2.3963]
|
||||
- [2.3968, 2.3968, 2.3968]
|
||||
- [2.3972, 2.3972, 2.3972]
|
||||
- [2.3976, 2.3976, 2.3976]
|
||||
- [2.3979, 2.3979, 2.3979]
|
||||
- [2.3982, 2.3982, 2.3982]
|
||||
- [2.3984, 2.3984, 2.3984]
|
||||
- [2.3986, 2.3986, 2.3986]
|
||||
- [2.3988, 2.3988, 2.3988]
|
||||
- [2.3989, 2.3989, 2.3989]
|
||||
- [2.3991, 2.3991, 2.3991]
|
||||
- [2.3992, 2.3992, 2.3992]
|
||||
- [2.3993, 2.3993, 2.3993]
|
||||
- [2.3994, 2.3994, 2.3994]
|
||||
- [2.3995, 2.3995, 2.3995]
|
||||
- [2.3995, 2.3995, 2.3995]
|
||||
- [2.3996, 2.3996, 2.3996]
|
||||
- [2.3997, 2.3997, 2.3997]
|
||||
- [2.3997, 2.3997, 2.3997]
|
||||
- [2.3997, 2.3997, 2.3997]
|
||||
- [2.3998, 2.3998, 2.3998]
|
||||
@@ -1,460 +0,0 @@
|
||||
# Generated by tools/film-profiles/convert.py.
|
||||
#
|
||||
# *** CONSTRUCTED, NOT MEASURED. ***
|
||||
#
|
||||
# Ilford publish this film's spectral sensitivity and characteristic
|
||||
# curve as graphs, and no granularity figure at all, so this profile is
|
||||
# built rather than extracted. Its speed is the published ISO rating,
|
||||
# and its contrast is ISO 6:1993's normal development (average gradient
|
||||
# 0.62). Its spectral response is borrowed from Kodak Double-X, a
|
||||
# *measured* panchromatic negative, shifted by the speed difference.
|
||||
# Its granularity is an estimate, ordered against the other films here.
|
||||
#
|
||||
# So it renders as a film of this speed and contrast, not as a
|
||||
# measurement of this emulsion. Replace it outright if anyone digitises
|
||||
# the real curves.
|
||||
|
||||
version: 'constructed-1'
|
||||
stock: ilford_fp4_plus
|
||||
name: 'Ilford FP4 Plus'
|
||||
kind: negative # negative | positive
|
||||
support: film # film | paper
|
||||
stage: filming # filming | printing
|
||||
monochrome: true
|
||||
target_print: kodak_2302
|
||||
reference_illuminant: D55
|
||||
viewing_illuminant: D50
|
||||
|
||||
rms_granularity: [7, 7, 7]
|
||||
|
||||
# log10 spectral sensitivity per layer, 380-780nm at 5nm.
|
||||
log_sensitivity:
|
||||
- [-9, -9, -9] # 380nm
|
||||
- [-9, -9, -9] # 385nm
|
||||
- [-9, -9, -9] # 390nm
|
||||
- [-9, -9, -9] # 395nm
|
||||
- [-9, -9, -9] # 400nm
|
||||
- [-9, -9, -9] # 405nm
|
||||
- [-9, -9, -9] # 410nm
|
||||
- [-9, -9, -9] # 415nm
|
||||
- [-9, -9, -9] # 420nm
|
||||
- [-1.01825, -1.01825, -1.01825] # 425nm
|
||||
- [-1.01799, -1.01799, -1.01799] # 430nm
|
||||
- [-1.01799, -1.01799, -1.01799] # 435nm
|
||||
- [-1.01834, -1.01834, -1.01834] # 440nm
|
||||
- [-1.02804, -1.02804, -1.02804] # 445nm
|
||||
- [-1.04077, -1.04077, -1.04077] # 450nm
|
||||
- [-1.05756, -1.05756, -1.05756] # 455nm
|
||||
- [-1.07682, -1.07682, -1.07682] # 460nm
|
||||
- [-1.09827, -1.09827, -1.09827] # 465nm
|
||||
- [-1.1167, -1.1167, -1.1167] # 470nm
|
||||
- [-1.14259, -1.14259, -1.14259] # 475nm
|
||||
- [-1.17037, -1.17037, -1.17037] # 480nm
|
||||
- [-1.20831, -1.20831, -1.20831] # 485nm
|
||||
- [-1.24806, -1.24806, -1.24806] # 490nm
|
||||
- [-1.28774, -1.28774, -1.28774] # 495nm
|
||||
- [-1.32101, -1.32101, -1.32101] # 500nm
|
||||
- [-1.33629, -1.33629, -1.33629] # 505nm
|
||||
- [-1.33946, -1.33946, -1.33946] # 510nm
|
||||
- [-1.33875, -1.33875, -1.33875] # 515nm
|
||||
- [-1.32898, -1.32898, -1.32898] # 520nm
|
||||
- [-1.31596, -1.31596, -1.31596] # 525nm
|
||||
- [-1.30119, -1.30119, -1.30119] # 530nm
|
||||
- [-1.28323, -1.28323, -1.28323] # 535nm
|
||||
- [-1.26501, -1.26501, -1.26501] # 540nm
|
||||
- [-1.24614, -1.24614, -1.24614] # 545nm
|
||||
- [-1.22957, -1.22957, -1.22957] # 550nm
|
||||
- [-1.2118, -1.2118, -1.2118] # 555nm
|
||||
- [-1.19855, -1.19855, -1.19855] # 560nm
|
||||
- [-1.19114, -1.19114, -1.19114] # 565nm
|
||||
- [-1.18626, -1.18626, -1.18626] # 570nm
|
||||
- [-1.1827, -1.1827, -1.1827] # 575nm
|
||||
- [-1.18551, -1.18551, -1.18551] # 580nm
|
||||
- [-1.19194, -1.19194, -1.19194] # 585nm
|
||||
- [-1.20146, -1.20146, -1.20146] # 590nm
|
||||
- [-1.2141, -1.2141, -1.2141] # 595nm
|
||||
- [-1.22597, -1.22597, -1.22597] # 600nm
|
||||
- [-1.24114, -1.24114, -1.24114] # 605nm
|
||||
- [-1.25625, -1.25625, -1.25625] # 610nm
|
||||
- [-1.26128, -1.26128, -1.26128] # 615nm
|
||||
- [-1.25287, -1.25287, -1.25287] # 620nm
|
||||
- [-1.26184, -1.26184, -1.26184] # 625nm
|
||||
- [-1.29098, -1.29098, -1.29098] # 630nm
|
||||
- [-1.38134, -1.38134, -1.38134] # 635nm
|
||||
- [-1.57076, -1.57076, -1.57076] # 640nm
|
||||
- [-1.7864, -1.7864, -1.7864] # 645nm
|
||||
- [-2.04202, -2.04202, -2.04202] # 650nm
|
||||
- [-2.30388, -2.30388, -2.30388] # 655nm
|
||||
- [-2.57175, -2.57175, -2.57175] # 660nm
|
||||
- [-9, -9, -9] # 665nm
|
||||
- [-9, -9, -9] # 670nm
|
||||
- [-9, -9, -9] # 675nm
|
||||
- [-9, -9, -9] # 680nm
|
||||
- [-9, -9, -9] # 685nm
|
||||
- [-9, -9, -9] # 690nm
|
||||
- [-9, -9, -9] # 695nm
|
||||
- [-9, -9, -9] # 700nm
|
||||
- [-9, -9, -9] # 705nm
|
||||
- [-9, -9, -9] # 710nm
|
||||
- [-9, -9, -9] # 715nm
|
||||
- [-9, -9, -9] # 720nm
|
||||
- [-9, -9, -9] # 725nm
|
||||
- [-9, -9, -9] # 730nm
|
||||
- [-9, -9, -9] # 735nm
|
||||
- [-9, -9, -9] # 740nm
|
||||
- [-9, -9, -9] # 745nm
|
||||
- [-9, -9, -9] # 750nm
|
||||
- [-9, -9, -9] # 755nm
|
||||
- [-9, -9, -9] # 760nm
|
||||
- [-9, -9, -9] # 765nm
|
||||
- [-9, -9, -9] # 770nm
|
||||
- [-9, -9, -9] # 775nm
|
||||
- [-9, -9, -9] # 780nm
|
||||
|
||||
# Developed silver: neutral across the band.
|
||||
dye_density:
|
||||
- [0.333333, 0.333333, 0.333333] # 380nm
|
||||
- [0.333333, 0.333333, 0.333333] # 385nm
|
||||
- [0.333333, 0.333333, 0.333333] # 390nm
|
||||
- [0.333333, 0.333333, 0.333333] # 395nm
|
||||
- [0.333333, 0.333333, 0.333333] # 400nm
|
||||
- [0.333333, 0.333333, 0.333333] # 405nm
|
||||
- [0.333333, 0.333333, 0.333333] # 410nm
|
||||
- [0.333333, 0.333333, 0.333333] # 415nm
|
||||
- [0.333333, 0.333333, 0.333333] # 420nm
|
||||
- [0.333333, 0.333333, 0.333333] # 425nm
|
||||
- [0.333333, 0.333333, 0.333333] # 430nm
|
||||
- [0.333333, 0.333333, 0.333333] # 435nm
|
||||
- [0.333333, 0.333333, 0.333333] # 440nm
|
||||
- [0.333333, 0.333333, 0.333333] # 445nm
|
||||
- [0.333333, 0.333333, 0.333333] # 450nm
|
||||
- [0.333333, 0.333333, 0.333333] # 455nm
|
||||
- [0.333333, 0.333333, 0.333333] # 460nm
|
||||
- [0.333333, 0.333333, 0.333333] # 465nm
|
||||
- [0.333333, 0.333333, 0.333333] # 470nm
|
||||
- [0.333333, 0.333333, 0.333333] # 475nm
|
||||
- [0.333333, 0.333333, 0.333333] # 480nm
|
||||
- [0.333333, 0.333333, 0.333333] # 485nm
|
||||
- [0.333333, 0.333333, 0.333333] # 490nm
|
||||
- [0.333333, 0.333333, 0.333333] # 495nm
|
||||
- [0.333333, 0.333333, 0.333333] # 500nm
|
||||
- [0.333333, 0.333333, 0.333333] # 505nm
|
||||
- [0.333333, 0.333333, 0.333333] # 510nm
|
||||
- [0.333333, 0.333333, 0.333333] # 515nm
|
||||
- [0.333333, 0.333333, 0.333333] # 520nm
|
||||
- [0.333333, 0.333333, 0.333333] # 525nm
|
||||
- [0.333333, 0.333333, 0.333333] # 530nm
|
||||
- [0.333333, 0.333333, 0.333333] # 535nm
|
||||
- [0.333333, 0.333333, 0.333333] # 540nm
|
||||
- [0.333333, 0.333333, 0.333333] # 545nm
|
||||
- [0.333333, 0.333333, 0.333333] # 550nm
|
||||
- [0.333333, 0.333333, 0.333333] # 555nm
|
||||
- [0.333333, 0.333333, 0.333333] # 560nm
|
||||
- [0.333333, 0.333333, 0.333333] # 565nm
|
||||
- [0.333333, 0.333333, 0.333333] # 570nm
|
||||
- [0.333333, 0.333333, 0.333333] # 575nm
|
||||
- [0.333333, 0.333333, 0.333333] # 580nm
|
||||
- [0.333333, 0.333333, 0.333333] # 585nm
|
||||
- [0.333333, 0.333333, 0.333333] # 590nm
|
||||
- [0.333333, 0.333333, 0.333333] # 595nm
|
||||
- [0.333333, 0.333333, 0.333333] # 600nm
|
||||
- [0.333333, 0.333333, 0.333333] # 605nm
|
||||
- [0.333333, 0.333333, 0.333333] # 610nm
|
||||
- [0.333333, 0.333333, 0.333333] # 615nm
|
||||
- [0.333333, 0.333333, 0.333333] # 620nm
|
||||
- [0.333333, 0.333333, 0.333333] # 625nm
|
||||
- [0.333333, 0.333333, 0.333333] # 630nm
|
||||
- [0.333333, 0.333333, 0.333333] # 635nm
|
||||
- [0.333333, 0.333333, 0.333333] # 640nm
|
||||
- [0.333333, 0.333333, 0.333333] # 645nm
|
||||
- [0.333333, 0.333333, 0.333333] # 650nm
|
||||
- [0.333333, 0.333333, 0.333333] # 655nm
|
||||
- [0.333333, 0.333333, 0.333333] # 660nm
|
||||
- [0.333333, 0.333333, 0.333333] # 665nm
|
||||
- [0.333333, 0.333333, 0.333333] # 670nm
|
||||
- [0.333333, 0.333333, 0.333333] # 675nm
|
||||
- [0.333333, 0.333333, 0.333333] # 680nm
|
||||
- [0.333333, 0.333333, 0.333333] # 685nm
|
||||
- [0.333333, 0.333333, 0.333333] # 690nm
|
||||
- [0.333333, 0.333333, 0.333333] # 695nm
|
||||
- [0.333333, 0.333333, 0.333333] # 700nm
|
||||
- [0.333333, 0.333333, 0.333333] # 705nm
|
||||
- [0.333333, 0.333333, 0.333333] # 710nm
|
||||
- [0.333333, 0.333333, 0.333333] # 715nm
|
||||
- [0.333333, 0.333333, 0.333333] # 720nm
|
||||
- [0.333333, 0.333333, 0.333333] # 725nm
|
||||
- [0.333333, 0.333333, 0.333333] # 730nm
|
||||
- [0.333333, 0.333333, 0.333333] # 735nm
|
||||
- [0.333333, 0.333333, 0.333333] # 740nm
|
||||
- [0.333333, 0.333333, 0.333333] # 745nm
|
||||
- [0.333333, 0.333333, 0.333333] # 750nm
|
||||
- [0.333333, 0.333333, 0.333333] # 755nm
|
||||
- [0.333333, 0.333333, 0.333333] # 760nm
|
||||
- [0.333333, 0.333333, 0.333333] # 765nm
|
||||
- [0.333333, 0.333333, 0.333333] # 770nm
|
||||
- [0.333333, 0.333333, 0.333333] # 775nm
|
||||
- [0.333333, 0.333333, 0.333333] # 780nm
|
||||
|
||||
# Base plus fog. No orange mask on a black-and-white film.
|
||||
base_density: [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]
|
||||
|
||||
# The characteristic curve, parametric.
|
||||
log_exposure_min: -3
|
||||
log_exposure_max: 4
|
||||
density_curves:
|
||||
- [3.5129e-07, 3.5129e-07, 3.5129e-07]
|
||||
- [4.2083e-07, 4.2083e-07, 4.2083e-07]
|
||||
- [5.0412e-07, 5.0412e-07, 5.0412e-07]
|
||||
- [6.039e-07, 6.039e-07, 6.039e-07]
|
||||
- [7.2343e-07, 7.2343e-07, 7.2343e-07]
|
||||
- [8.6662e-07, 8.6662e-07, 8.6662e-07]
|
||||
- [1.0381e-06, 1.0381e-06, 1.0381e-06]
|
||||
- [1.2436e-06, 1.2436e-06, 1.2436e-06]
|
||||
- [1.4898e-06, 1.4898e-06, 1.4898e-06]
|
||||
- [1.7846e-06, 1.7846e-06, 1.7846e-06]
|
||||
- [2.1379e-06, 2.1379e-06, 2.1379e-06]
|
||||
- [2.561e-06, 2.561e-06, 2.561e-06]
|
||||
- [3.0679e-06, 3.0679e-06, 3.0679e-06]
|
||||
- [3.6751e-06, 3.6751e-06, 3.6751e-06]
|
||||
- [4.4025e-06, 4.4025e-06, 4.4025e-06]
|
||||
- [5.2739e-06, 5.2739e-06, 5.2739e-06]
|
||||
- [6.3177e-06, 6.3177e-06, 6.3177e-06]
|
||||
- [7.5681e-06, 7.5681e-06, 7.5681e-06]
|
||||
- [9.066e-06, 9.066e-06, 9.066e-06]
|
||||
- [1.086e-05, 1.086e-05, 1.086e-05]
|
||||
- [1.301e-05, 1.301e-05, 1.301e-05]
|
||||
- [1.5585e-05, 1.5585e-05, 1.5585e-05]
|
||||
- [1.8669e-05, 1.8669e-05, 1.8669e-05]
|
||||
- [2.2364e-05, 2.2364e-05, 2.2364e-05]
|
||||
- [2.6789e-05, 2.6789e-05, 2.6789e-05]
|
||||
- [3.2091e-05, 3.2091e-05, 3.2091e-05]
|
||||
- [3.8441e-05, 3.8441e-05, 3.8441e-05]
|
||||
- [4.6048e-05, 4.6048e-05, 4.6048e-05]
|
||||
- [5.516e-05, 5.516e-05, 5.516e-05]
|
||||
- [6.6074e-05, 6.6074e-05, 6.6074e-05]
|
||||
- [7.9146e-05, 7.9146e-05, 7.9146e-05]
|
||||
- [9.4804e-05, 9.4804e-05, 9.4804e-05]
|
||||
- [0.00011356, 0.00011356, 0.00011356]
|
||||
- [0.00013602, 0.00013602, 0.00013602]
|
||||
- [0.00016292, 0.00016292, 0.00016292]
|
||||
- [0.00019513, 0.00019513, 0.00019513]
|
||||
- [0.0002337, 0.0002337, 0.0002337]
|
||||
- [0.00027989, 0.00027989, 0.00027989]
|
||||
- [0.00033519, 0.00033519, 0.00033519]
|
||||
- [0.00040139, 0.00040139, 0.00040139]
|
||||
- [0.00048064, 0.00048064, 0.00048064]
|
||||
- [0.00057548, 0.00057548, 0.00057548]
|
||||
- [0.00068897, 0.00068897, 0.00068897]
|
||||
- [0.00082475, 0.00082475, 0.00082475]
|
||||
- [0.00098713, 0.00098713, 0.00098713]
|
||||
- [0.0011813, 0.0011813, 0.0011813]
|
||||
- [0.0014134, 0.0014134, 0.0014134]
|
||||
- [0.0016906, 0.0016906, 0.0016906]
|
||||
- [0.0020217, 0.0020217, 0.0020217]
|
||||
- [0.0024167, 0.0024167, 0.0024167]
|
||||
- [0.0028878, 0.0028878, 0.0028878]
|
||||
- [0.0034491, 0.0034491, 0.0034491]
|
||||
- [0.004117, 0.004117, 0.004117]
|
||||
- [0.004911, 0.004911, 0.004911]
|
||||
- [0.0058534, 0.0058534, 0.0058534]
|
||||
- [0.0069701, 0.0069701, 0.0069701]
|
||||
- [0.0082906, 0.0082906, 0.0082906]
|
||||
- [0.0098485, 0.0098485, 0.0098485]
|
||||
- [0.011681, 0.011681, 0.011681]
|
||||
- [0.013831, 0.013831, 0.013831]
|
||||
- [0.016344, 0.016344, 0.016344]
|
||||
- [0.019268, 0.019268, 0.019268]
|
||||
- [0.022655, 0.022655, 0.022655]
|
||||
- [0.026559, 0.026559, 0.026559]
|
||||
- [0.031032, 0.031032, 0.031032]
|
||||
- [0.036126, 0.036126, 0.036126]
|
||||
- [0.041886, 0.041886, 0.041886]
|
||||
- [0.048352, 0.048352, 0.048352]
|
||||
- [0.055556, 0.055556, 0.055556]
|
||||
- [0.063518, 0.063518, 0.063518]
|
||||
- [0.072247, 0.072247, 0.072247]
|
||||
- [0.081739, 0.081739, 0.081739]
|
||||
- [0.09198, 0.09198, 0.09198]
|
||||
- [0.10294, 0.10294, 0.10294]
|
||||
- [0.11459, 0.11459, 0.11459]
|
||||
- [0.12687, 0.12687, 0.12687]
|
||||
- [0.13975, 0.13975, 0.13975]
|
||||
- [0.15317, 0.15317, 0.15317]
|
||||
- [0.16707, 0.16707, 0.16707]
|
||||
- [0.18141, 0.18141, 0.18141]
|
||||
- [0.19613, 0.19613, 0.19613]
|
||||
- [0.21119, 0.21119, 0.21119]
|
||||
- [0.22653, 0.22653, 0.22653]
|
||||
- [0.24214, 0.24214, 0.24214]
|
||||
- [0.25796, 0.25796, 0.25796]
|
||||
- [0.27396, 0.27396, 0.27396]
|
||||
- [0.29013, 0.29013, 0.29013]
|
||||
- [0.30643, 0.30643, 0.30643]
|
||||
- [0.32284, 0.32284, 0.32284]
|
||||
- [0.33935, 0.33935, 0.33935]
|
||||
- [0.35595, 0.35595, 0.35595]
|
||||
- [0.37261, 0.37261, 0.37261]
|
||||
- [0.38933, 0.38933, 0.38933]
|
||||
- [0.4061, 0.4061, 0.4061]
|
||||
- [0.42291, 0.42291, 0.42291]
|
||||
- [0.43975, 0.43975, 0.43975]
|
||||
- [0.45663, 0.45663, 0.45663]
|
||||
- [0.47352, 0.47352, 0.47352]
|
||||
- [0.49044, 0.49044, 0.49044]
|
||||
- [0.50737, 0.50737, 0.50737]
|
||||
- [0.52432, 0.52432, 0.52432]
|
||||
- [0.54128, 0.54128, 0.54128]
|
||||
- [0.55825, 0.55825, 0.55825]
|
||||
- [0.57523, 0.57523, 0.57523]
|
||||
- [0.59222, 0.59222, 0.59222]
|
||||
- [0.60921, 0.60921, 0.60921]
|
||||
- [0.6262, 0.6262, 0.6262]
|
||||
- [0.6432, 0.6432, 0.6432]
|
||||
- [0.6602, 0.6602, 0.6602]
|
||||
- [0.67721, 0.67721, 0.67721]
|
||||
- [0.69422, 0.69422, 0.69422]
|
||||
- [0.71123, 0.71123, 0.71123]
|
||||
- [0.72824, 0.72824, 0.72824]
|
||||
- [0.74525, 0.74525, 0.74525]
|
||||
- [0.76226, 0.76226, 0.76226]
|
||||
- [0.77928, 0.77928, 0.77928]
|
||||
- [0.79629, 0.79629, 0.79629]
|
||||
- [0.81331, 0.81331, 0.81331]
|
||||
- [0.83033, 0.83033, 0.83033]
|
||||
- [0.84734, 0.84734, 0.84734]
|
||||
- [0.86436, 0.86436, 0.86436]
|
||||
- [0.88138, 0.88138, 0.88138]
|
||||
- [0.8984, 0.8984, 0.8984]
|
||||
- [0.91542, 0.91542, 0.91542]
|
||||
- [0.93244, 0.93244, 0.93244]
|
||||
- [0.94945, 0.94945, 0.94945]
|
||||
- [0.96647, 0.96647, 0.96647]
|
||||
- [0.98349, 0.98349, 0.98349]
|
||||
- [1.0005, 1.0005, 1.0005]
|
||||
- [1.0175, 1.0175, 1.0175]
|
||||
- [1.0345, 1.0345, 1.0345]
|
||||
- [1.0516, 1.0516, 1.0516]
|
||||
- [1.0686, 1.0686, 1.0686]
|
||||
- [1.0856, 1.0856, 1.0856]
|
||||
- [1.1026, 1.1026, 1.1026]
|
||||
- [1.1196, 1.1196, 1.1196]
|
||||
- [1.1367, 1.1367, 1.1367]
|
||||
- [1.1537, 1.1537, 1.1537]
|
||||
- [1.1707, 1.1707, 1.1707]
|
||||
- [1.1877, 1.1877, 1.1877]
|
||||
- [1.2047, 1.2047, 1.2047]
|
||||
- [1.2218, 1.2218, 1.2218]
|
||||
- [1.2388, 1.2388, 1.2388]
|
||||
- [1.2558, 1.2558, 1.2558]
|
||||
- [1.2728, 1.2728, 1.2728]
|
||||
- [1.2898, 1.2898, 1.2898]
|
||||
- [1.3069, 1.3069, 1.3069]
|
||||
- [1.3239, 1.3239, 1.3239]
|
||||
- [1.3409, 1.3409, 1.3409]
|
||||
- [1.3579, 1.3579, 1.3579]
|
||||
- [1.3749, 1.3749, 1.3749]
|
||||
- [1.3919, 1.3919, 1.3919]
|
||||
- [1.409, 1.409, 1.409]
|
||||
- [1.426, 1.426, 1.426]
|
||||
- [1.443, 1.443, 1.443]
|
||||
- [1.46, 1.46, 1.46]
|
||||
- [1.477, 1.477, 1.477]
|
||||
- [1.494, 1.494, 1.494]
|
||||
- [1.5111, 1.5111, 1.5111]
|
||||
- [1.5281, 1.5281, 1.5281]
|
||||
- [1.5451, 1.5451, 1.5451]
|
||||
- [1.5621, 1.5621, 1.5621]
|
||||
- [1.5791, 1.5791, 1.5791]
|
||||
- [1.5961, 1.5961, 1.5961]
|
||||
- [1.6131, 1.6131, 1.6131]
|
||||
- [1.6301, 1.6301, 1.6301]
|
||||
- [1.6471, 1.6471, 1.6471]
|
||||
- [1.6642, 1.6642, 1.6642]
|
||||
- [1.6812, 1.6812, 1.6812]
|
||||
- [1.6982, 1.6982, 1.6982]
|
||||
- [1.7151, 1.7151, 1.7151]
|
||||
- [1.7321, 1.7321, 1.7321]
|
||||
- [1.7491, 1.7491, 1.7491]
|
||||
- [1.7661, 1.7661, 1.7661]
|
||||
- [1.7831, 1.7831, 1.7831]
|
||||
- [1.8001, 1.8001, 1.8001]
|
||||
- [1.817, 1.817, 1.817]
|
||||
- [1.834, 1.834, 1.834]
|
||||
- [1.8509, 1.8509, 1.8509]
|
||||
- [1.8679, 1.8679, 1.8679]
|
||||
- [1.8848, 1.8848, 1.8848]
|
||||
- [1.9017, 1.9017, 1.9017]
|
||||
- [1.9186, 1.9186, 1.9186]
|
||||
- [1.9354, 1.9354, 1.9354]
|
||||
- [1.9523, 1.9523, 1.9523]
|
||||
- [1.9691, 1.9691, 1.9691]
|
||||
- [1.9859, 1.9859, 1.9859]
|
||||
- [2.0027, 2.0027, 2.0027]
|
||||
- [2.0194, 2.0194, 2.0194]
|
||||
- [2.036, 2.036, 2.036]
|
||||
- [2.0527, 2.0527, 2.0527]
|
||||
- [2.0692, 2.0692, 2.0692]
|
||||
- [2.0857, 2.0857, 2.0857]
|
||||
- [2.1022, 2.1022, 2.1022]
|
||||
- [2.1185, 2.1185, 2.1185]
|
||||
- [2.1347, 2.1347, 2.1347]
|
||||
- [2.1509, 2.1509, 2.1509]
|
||||
- [2.1669, 2.1669, 2.1669]
|
||||
- [2.1827, 2.1827, 2.1827]
|
||||
- [2.1984, 2.1984, 2.1984]
|
||||
- [2.2139, 2.2139, 2.2139]
|
||||
- [2.2292, 2.2292, 2.2292]
|
||||
- [2.2443, 2.2443, 2.2443]
|
||||
- [2.2591, 2.2591, 2.2591]
|
||||
- [2.2737, 2.2737, 2.2737]
|
||||
- [2.2879, 2.2879, 2.2879]
|
||||
- [2.3018, 2.3018, 2.3018]
|
||||
- [2.3153, 2.3153, 2.3153]
|
||||
- [2.3284, 2.3284, 2.3284]
|
||||
- [2.3411, 2.3411, 2.3411]
|
||||
- [2.3533, 2.3533, 2.3533]
|
||||
- [2.365, 2.365, 2.365]
|
||||
- [2.3761, 2.3761, 2.3761]
|
||||
- [2.3867, 2.3867, 2.3867]
|
||||
- [2.3968, 2.3968, 2.3968]
|
||||
- [2.4063, 2.4063, 2.4063]
|
||||
- [2.4151, 2.4151, 2.4151]
|
||||
- [2.4234, 2.4234, 2.4234]
|
||||
- [2.4311, 2.4311, 2.4311]
|
||||
- [2.4382, 2.4382, 2.4382]
|
||||
- [2.4447, 2.4447, 2.4447]
|
||||
- [2.4507, 2.4507, 2.4507]
|
||||
- [2.4561, 2.4561, 2.4561]
|
||||
- [2.461, 2.461, 2.461]
|
||||
- [2.4655, 2.4655, 2.4655]
|
||||
- [2.4695, 2.4695, 2.4695]
|
||||
- [2.4731, 2.4731, 2.4731]
|
||||
- [2.4763, 2.4763, 2.4763]
|
||||
- [2.4791, 2.4791, 2.4791]
|
||||
- [2.4817, 2.4817, 2.4817]
|
||||
- [2.4839, 2.4839, 2.4839]
|
||||
- [2.4859, 2.4859, 2.4859]
|
||||
- [2.4877, 2.4877, 2.4877]
|
||||
- [2.4892, 2.4892, 2.4892]
|
||||
- [2.4906, 2.4906, 2.4906]
|
||||
- [2.4918, 2.4918, 2.4918]
|
||||
- [2.4928, 2.4928, 2.4928]
|
||||
- [2.4937, 2.4937, 2.4937]
|
||||
- [2.4945, 2.4945, 2.4945]
|
||||
- [2.4952, 2.4952, 2.4952]
|
||||
- [2.4958, 2.4958, 2.4958]
|
||||
- [2.4964, 2.4964, 2.4964]
|
||||
- [2.4969, 2.4969, 2.4969]
|
||||
- [2.4973, 2.4973, 2.4973]
|
||||
- [2.4976, 2.4976, 2.4976]
|
||||
- [2.4979, 2.4979, 2.4979]
|
||||
- [2.4982, 2.4982, 2.4982]
|
||||
- [2.4984, 2.4984, 2.4984]
|
||||
- [2.4986, 2.4986, 2.4986]
|
||||
- [2.4988, 2.4988, 2.4988]
|
||||
- [2.499, 2.499, 2.499]
|
||||
- [2.4991, 2.4991, 2.4991]
|
||||
- [2.4992, 2.4992, 2.4992]
|
||||
- [2.4993, 2.4993, 2.4993]
|
||||
- [2.4994, 2.4994, 2.4994]
|
||||
- [2.4995, 2.4995, 2.4995]
|
||||
@@ -1,460 +0,0 @@
|
||||
# Generated by tools/film-profiles/convert.py.
|
||||
#
|
||||
# *** CONSTRUCTED, NOT MEASURED. ***
|
||||
#
|
||||
# Ilford publish this film's spectral sensitivity and characteristic
|
||||
# curve as graphs, and no granularity figure at all, so this profile is
|
||||
# built rather than extracted. Its speed is the published ISO rating,
|
||||
# and its contrast is ISO 6:1993's normal development (average gradient
|
||||
# 0.62). Its spectral response is borrowed from Kodak Double-X, a
|
||||
# *measured* panchromatic negative, shifted by the speed difference.
|
||||
# Its granularity is an estimate, ordered against the other films here.
|
||||
#
|
||||
# So it renders as a film of this speed and contrast, not as a
|
||||
# measurement of this emulsion. Replace it outright if anyone digitises
|
||||
# the real curves.
|
||||
|
||||
version: 'constructed-1'
|
||||
stock: ilford_hp5_plus
|
||||
name: 'Ilford HP5 Plus'
|
||||
kind: negative # negative | positive
|
||||
support: film # film | paper
|
||||
stage: filming # filming | printing
|
||||
monochrome: true
|
||||
target_print: kodak_2302
|
||||
reference_illuminant: D55
|
||||
viewing_illuminant: D50
|
||||
|
||||
rms_granularity: [11, 11, 11]
|
||||
|
||||
# log10 spectral sensitivity per layer, 380-780nm at 5nm.
|
||||
log_sensitivity:
|
||||
- [-9, -9, -9] # 380nm
|
||||
- [-9, -9, -9] # 385nm
|
||||
- [-9, -9, -9] # 390nm
|
||||
- [-9, -9, -9] # 395nm
|
||||
- [-9, -9, -9] # 400nm
|
||||
- [-9, -9, -9] # 405nm
|
||||
- [-9, -9, -9] # 410nm
|
||||
- [-9, -9, -9] # 415nm
|
||||
- [-9, -9, -9] # 420nm
|
||||
- [-0.513098, -0.513098, -0.513098] # 425nm
|
||||
- [-0.51284, -0.51284, -0.51284] # 430nm
|
||||
- [-0.51284, -0.51284, -0.51284] # 435nm
|
||||
- [-0.51319, -0.51319, -0.51319] # 440nm
|
||||
- [-0.522893, -0.522893, -0.522893] # 445nm
|
||||
- [-0.53562, -0.53562, -0.53562] # 450nm
|
||||
- [-0.552412, -0.552412, -0.552412] # 455nm
|
||||
- [-0.571665, -0.571665, -0.571665] # 460nm
|
||||
- [-0.593122, -0.593122, -0.593122] # 465nm
|
||||
- [-0.611553, -0.611553, -0.611553] # 470nm
|
||||
- [-0.637438, -0.637438, -0.637438] # 475nm
|
||||
- [-0.665217, -0.665217, -0.665217] # 480nm
|
||||
- [-0.703161, -0.703161, -0.703161] # 485nm
|
||||
- [-0.742909, -0.742909, -0.742909] # 490nm
|
||||
- [-0.782594, -0.782594, -0.782594] # 495nm
|
||||
- [-0.815861, -0.815861, -0.815861] # 500nm
|
||||
- [-0.831143, -0.831143, -0.831143] # 505nm
|
||||
- [-0.834309, -0.834309, -0.834309] # 510nm
|
||||
- [-0.833599, -0.833599, -0.833599] # 515nm
|
||||
- [-0.823829, -0.823829, -0.823829] # 520nm
|
||||
- [-0.810809, -0.810809, -0.810809] # 525nm
|
||||
- [-0.796036, -0.796036, -0.796036] # 530nm
|
||||
- [-0.778081, -0.778081, -0.778081] # 535nm
|
||||
- [-0.759864, -0.759864, -0.759864] # 540nm
|
||||
- [-0.740987, -0.740987, -0.740987] # 545nm
|
||||
- [-0.724425, -0.724425, -0.724425] # 550nm
|
||||
- [-0.706653, -0.706653, -0.706653] # 555nm
|
||||
- [-0.693404, -0.693404, -0.693404] # 560nm
|
||||
- [-0.685993, -0.685993, -0.685993] # 565nm
|
||||
- [-0.68111, -0.68111, -0.68111] # 570nm
|
||||
- [-0.677547, -0.677547, -0.677547] # 575nm
|
||||
- [-0.680358, -0.680358, -0.680358] # 580nm
|
||||
- [-0.686794, -0.686794, -0.686794] # 585nm
|
||||
- [-0.696308, -0.696308, -0.696308] # 590nm
|
||||
- [-0.708955, -0.708955, -0.708955] # 595nm
|
||||
- [-0.720822, -0.720822, -0.720822] # 600nm
|
||||
- [-0.735995, -0.735995, -0.735995] # 605nm
|
||||
- [-0.751102, -0.751102, -0.751102] # 610nm
|
||||
- [-0.75613, -0.75613, -0.75613] # 615nm
|
||||
- [-0.747716, -0.747716, -0.747716] # 620nm
|
||||
- [-0.756689, -0.756689, -0.756689] # 625nm
|
||||
- [-0.785834, -0.785834, -0.785834] # 630nm
|
||||
- [-0.876194, -0.876194, -0.876194] # 635nm
|
||||
- [-1.06561, -1.06561, -1.06561] # 640nm
|
||||
- [-1.28125, -1.28125, -1.28125] # 645nm
|
||||
- [-1.53687, -1.53687, -1.53687] # 650nm
|
||||
- [-1.79873, -1.79873, -1.79873] # 655nm
|
||||
- [-2.0666, -2.0666, -2.0666] # 660nm
|
||||
- [-9, -9, -9] # 665nm
|
||||
- [-9, -9, -9] # 670nm
|
||||
- [-9, -9, -9] # 675nm
|
||||
- [-9, -9, -9] # 680nm
|
||||
- [-9, -9, -9] # 685nm
|
||||
- [-9, -9, -9] # 690nm
|
||||
- [-9, -9, -9] # 695nm
|
||||
- [-9, -9, -9] # 700nm
|
||||
- [-9, -9, -9] # 705nm
|
||||
- [-9, -9, -9] # 710nm
|
||||
- [-9, -9, -9] # 715nm
|
||||
- [-9, -9, -9] # 720nm
|
||||
- [-9, -9, -9] # 725nm
|
||||
- [-9, -9, -9] # 730nm
|
||||
- [-9, -9, -9] # 735nm
|
||||
- [-9, -9, -9] # 740nm
|
||||
- [-9, -9, -9] # 745nm
|
||||
- [-9, -9, -9] # 750nm
|
||||
- [-9, -9, -9] # 755nm
|
||||
- [-9, -9, -9] # 760nm
|
||||
- [-9, -9, -9] # 765nm
|
||||
- [-9, -9, -9] # 770nm
|
||||
- [-9, -9, -9] # 775nm
|
||||
- [-9, -9, -9] # 780nm
|
||||
|
||||
# Developed silver: neutral across the band.
|
||||
dye_density:
|
||||
- [0.333333, 0.333333, 0.333333] # 380nm
|
||||
- [0.333333, 0.333333, 0.333333] # 385nm
|
||||
- [0.333333, 0.333333, 0.333333] # 390nm
|
||||
- [0.333333, 0.333333, 0.333333] # 395nm
|
||||
- [0.333333, 0.333333, 0.333333] # 400nm
|
||||
- [0.333333, 0.333333, 0.333333] # 405nm
|
||||
- [0.333333, 0.333333, 0.333333] # 410nm
|
||||
- [0.333333, 0.333333, 0.333333] # 415nm
|
||||
- [0.333333, 0.333333, 0.333333] # 420nm
|
||||
- [0.333333, 0.333333, 0.333333] # 425nm
|
||||
- [0.333333, 0.333333, 0.333333] # 430nm
|
||||
- [0.333333, 0.333333, 0.333333] # 435nm
|
||||
- [0.333333, 0.333333, 0.333333] # 440nm
|
||||
- [0.333333, 0.333333, 0.333333] # 445nm
|
||||
- [0.333333, 0.333333, 0.333333] # 450nm
|
||||
- [0.333333, 0.333333, 0.333333] # 455nm
|
||||
- [0.333333, 0.333333, 0.333333] # 460nm
|
||||
- [0.333333, 0.333333, 0.333333] # 465nm
|
||||
- [0.333333, 0.333333, 0.333333] # 470nm
|
||||
- [0.333333, 0.333333, 0.333333] # 475nm
|
||||
- [0.333333, 0.333333, 0.333333] # 480nm
|
||||
- [0.333333, 0.333333, 0.333333] # 485nm
|
||||
- [0.333333, 0.333333, 0.333333] # 490nm
|
||||
- [0.333333, 0.333333, 0.333333] # 495nm
|
||||
- [0.333333, 0.333333, 0.333333] # 500nm
|
||||
- [0.333333, 0.333333, 0.333333] # 505nm
|
||||
- [0.333333, 0.333333, 0.333333] # 510nm
|
||||
- [0.333333, 0.333333, 0.333333] # 515nm
|
||||
- [0.333333, 0.333333, 0.333333] # 520nm
|
||||
- [0.333333, 0.333333, 0.333333] # 525nm
|
||||
- [0.333333, 0.333333, 0.333333] # 530nm
|
||||
- [0.333333, 0.333333, 0.333333] # 535nm
|
||||
- [0.333333, 0.333333, 0.333333] # 540nm
|
||||
- [0.333333, 0.333333, 0.333333] # 545nm
|
||||
- [0.333333, 0.333333, 0.333333] # 550nm
|
||||
- [0.333333, 0.333333, 0.333333] # 555nm
|
||||
- [0.333333, 0.333333, 0.333333] # 560nm
|
||||
- [0.333333, 0.333333, 0.333333] # 565nm
|
||||
- [0.333333, 0.333333, 0.333333] # 570nm
|
||||
- [0.333333, 0.333333, 0.333333] # 575nm
|
||||
- [0.333333, 0.333333, 0.333333] # 580nm
|
||||
- [0.333333, 0.333333, 0.333333] # 585nm
|
||||
- [0.333333, 0.333333, 0.333333] # 590nm
|
||||
- [0.333333, 0.333333, 0.333333] # 595nm
|
||||
- [0.333333, 0.333333, 0.333333] # 600nm
|
||||
- [0.333333, 0.333333, 0.333333] # 605nm
|
||||
- [0.333333, 0.333333, 0.333333] # 610nm
|
||||
- [0.333333, 0.333333, 0.333333] # 615nm
|
||||
- [0.333333, 0.333333, 0.333333] # 620nm
|
||||
- [0.333333, 0.333333, 0.333333] # 625nm
|
||||
- [0.333333, 0.333333, 0.333333] # 630nm
|
||||
- [0.333333, 0.333333, 0.333333] # 635nm
|
||||
- [0.333333, 0.333333, 0.333333] # 640nm
|
||||
- [0.333333, 0.333333, 0.333333] # 645nm
|
||||
- [0.333333, 0.333333, 0.333333] # 650nm
|
||||
- [0.333333, 0.333333, 0.333333] # 655nm
|
||||
- [0.333333, 0.333333, 0.333333] # 660nm
|
||||
- [0.333333, 0.333333, 0.333333] # 665nm
|
||||
- [0.333333, 0.333333, 0.333333] # 670nm
|
||||
- [0.333333, 0.333333, 0.333333] # 675nm
|
||||
- [0.333333, 0.333333, 0.333333] # 680nm
|
||||
- [0.333333, 0.333333, 0.333333] # 685nm
|
||||
- [0.333333, 0.333333, 0.333333] # 690nm
|
||||
- [0.333333, 0.333333, 0.333333] # 695nm
|
||||
- [0.333333, 0.333333, 0.333333] # 700nm
|
||||
- [0.333333, 0.333333, 0.333333] # 705nm
|
||||
- [0.333333, 0.333333, 0.333333] # 710nm
|
||||
- [0.333333, 0.333333, 0.333333] # 715nm
|
||||
- [0.333333, 0.333333, 0.333333] # 720nm
|
||||
- [0.333333, 0.333333, 0.333333] # 725nm
|
||||
- [0.333333, 0.333333, 0.333333] # 730nm
|
||||
- [0.333333, 0.333333, 0.333333] # 735nm
|
||||
- [0.333333, 0.333333, 0.333333] # 740nm
|
||||
- [0.333333, 0.333333, 0.333333] # 745nm
|
||||
- [0.333333, 0.333333, 0.333333] # 750nm
|
||||
- [0.333333, 0.333333, 0.333333] # 755nm
|
||||
- [0.333333, 0.333333, 0.333333] # 760nm
|
||||
- [0.333333, 0.333333, 0.333333] # 765nm
|
||||
- [0.333333, 0.333333, 0.333333] # 770nm
|
||||
- [0.333333, 0.333333, 0.333333] # 775nm
|
||||
- [0.333333, 0.333333, 0.333333] # 780nm
|
||||
|
||||
# Base plus fog. No orange mask on a black-and-white film.
|
||||
base_density: [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]
|
||||
|
||||
# The characteristic curve, parametric.
|
||||
log_exposure_min: -3
|
||||
log_exposure_max: 4
|
||||
density_curves:
|
||||
- [3.5129e-07, 3.5129e-07, 3.5129e-07]
|
||||
- [4.2083e-07, 4.2083e-07, 4.2083e-07]
|
||||
- [5.0412e-07, 5.0412e-07, 5.0412e-07]
|
||||
- [6.039e-07, 6.039e-07, 6.039e-07]
|
||||
- [7.2343e-07, 7.2343e-07, 7.2343e-07]
|
||||
- [8.6662e-07, 8.6662e-07, 8.6662e-07]
|
||||
- [1.0381e-06, 1.0381e-06, 1.0381e-06]
|
||||
- [1.2436e-06, 1.2436e-06, 1.2436e-06]
|
||||
- [1.4898e-06, 1.4898e-06, 1.4898e-06]
|
||||
- [1.7846e-06, 1.7846e-06, 1.7846e-06]
|
||||
- [2.1379e-06, 2.1379e-06, 2.1379e-06]
|
||||
- [2.561e-06, 2.561e-06, 2.561e-06]
|
||||
- [3.0679e-06, 3.0679e-06, 3.0679e-06]
|
||||
- [3.6751e-06, 3.6751e-06, 3.6751e-06]
|
||||
- [4.4025e-06, 4.4025e-06, 4.4025e-06]
|
||||
- [5.2739e-06, 5.2739e-06, 5.2739e-06]
|
||||
- [6.3177e-06, 6.3177e-06, 6.3177e-06]
|
||||
- [7.5681e-06, 7.5681e-06, 7.5681e-06]
|
||||
- [9.066e-06, 9.066e-06, 9.066e-06]
|
||||
- [1.086e-05, 1.086e-05, 1.086e-05]
|
||||
- [1.301e-05, 1.301e-05, 1.301e-05]
|
||||
- [1.5585e-05, 1.5585e-05, 1.5585e-05]
|
||||
- [1.8669e-05, 1.8669e-05, 1.8669e-05]
|
||||
- [2.2364e-05, 2.2364e-05, 2.2364e-05]
|
||||
- [2.6789e-05, 2.6789e-05, 2.6789e-05]
|
||||
- [3.2091e-05, 3.2091e-05, 3.2091e-05]
|
||||
- [3.8441e-05, 3.8441e-05, 3.8441e-05]
|
||||
- [4.6048e-05, 4.6048e-05, 4.6048e-05]
|
||||
- [5.516e-05, 5.516e-05, 5.516e-05]
|
||||
- [6.6074e-05, 6.6074e-05, 6.6074e-05]
|
||||
- [7.9146e-05, 7.9146e-05, 7.9146e-05]
|
||||
- [9.4804e-05, 9.4804e-05, 9.4804e-05]
|
||||
- [0.00011356, 0.00011356, 0.00011356]
|
||||
- [0.00013602, 0.00013602, 0.00013602]
|
||||
- [0.00016292, 0.00016292, 0.00016292]
|
||||
- [0.00019513, 0.00019513, 0.00019513]
|
||||
- [0.0002337, 0.0002337, 0.0002337]
|
||||
- [0.00027989, 0.00027989, 0.00027989]
|
||||
- [0.00033519, 0.00033519, 0.00033519]
|
||||
- [0.00040139, 0.00040139, 0.00040139]
|
||||
- [0.00048064, 0.00048064, 0.00048064]
|
||||
- [0.00057548, 0.00057548, 0.00057548]
|
||||
- [0.00068897, 0.00068897, 0.00068897]
|
||||
- [0.00082475, 0.00082475, 0.00082475]
|
||||
- [0.00098713, 0.00098713, 0.00098713]
|
||||
- [0.0011813, 0.0011813, 0.0011813]
|
||||
- [0.0014134, 0.0014134, 0.0014134]
|
||||
- [0.0016906, 0.0016906, 0.0016906]
|
||||
- [0.0020217, 0.0020217, 0.0020217]
|
||||
- [0.0024167, 0.0024167, 0.0024167]
|
||||
- [0.0028878, 0.0028878, 0.0028878]
|
||||
- [0.0034491, 0.0034491, 0.0034491]
|
||||
- [0.004117, 0.004117, 0.004117]
|
||||
- [0.004911, 0.004911, 0.004911]
|
||||
- [0.0058534, 0.0058534, 0.0058534]
|
||||
- [0.0069701, 0.0069701, 0.0069701]
|
||||
- [0.0082906, 0.0082906, 0.0082906]
|
||||
- [0.0098485, 0.0098485, 0.0098485]
|
||||
- [0.011681, 0.011681, 0.011681]
|
||||
- [0.013831, 0.013831, 0.013831]
|
||||
- [0.016344, 0.016344, 0.016344]
|
||||
- [0.019268, 0.019268, 0.019268]
|
||||
- [0.022655, 0.022655, 0.022655]
|
||||
- [0.026559, 0.026559, 0.026559]
|
||||
- [0.031032, 0.031032, 0.031032]
|
||||
- [0.036126, 0.036126, 0.036126]
|
||||
- [0.041886, 0.041886, 0.041886]
|
||||
- [0.048352, 0.048352, 0.048352]
|
||||
- [0.055556, 0.055556, 0.055556]
|
||||
- [0.063518, 0.063518, 0.063518]
|
||||
- [0.072247, 0.072247, 0.072247]
|
||||
- [0.081739, 0.081739, 0.081739]
|
||||
- [0.09198, 0.09198, 0.09198]
|
||||
- [0.10294, 0.10294, 0.10294]
|
||||
- [0.11459, 0.11459, 0.11459]
|
||||
- [0.12687, 0.12687, 0.12687]
|
||||
- [0.13975, 0.13975, 0.13975]
|
||||
- [0.15317, 0.15317, 0.15317]
|
||||
- [0.16707, 0.16707, 0.16707]
|
||||
- [0.18141, 0.18141, 0.18141]
|
||||
- [0.19613, 0.19613, 0.19613]
|
||||
- [0.21119, 0.21119, 0.21119]
|
||||
- [0.22653, 0.22653, 0.22653]
|
||||
- [0.24214, 0.24214, 0.24214]
|
||||
- [0.25796, 0.25796, 0.25796]
|
||||
- [0.27396, 0.27396, 0.27396]
|
||||
- [0.29013, 0.29013, 0.29013]
|
||||
- [0.30643, 0.30643, 0.30643]
|
||||
- [0.32284, 0.32284, 0.32284]
|
||||
- [0.33935, 0.33935, 0.33935]
|
||||
- [0.35595, 0.35595, 0.35595]
|
||||
- [0.37261, 0.37261, 0.37261]
|
||||
- [0.38933, 0.38933, 0.38933]
|
||||
- [0.4061, 0.4061, 0.4061]
|
||||
- [0.42291, 0.42291, 0.42291]
|
||||
- [0.43975, 0.43975, 0.43975]
|
||||
- [0.45663, 0.45663, 0.45663]
|
||||
- [0.47352, 0.47352, 0.47352]
|
||||
- [0.49044, 0.49044, 0.49044]
|
||||
- [0.50737, 0.50737, 0.50737]
|
||||
- [0.52432, 0.52432, 0.52432]
|
||||
- [0.54128, 0.54128, 0.54128]
|
||||
- [0.55825, 0.55825, 0.55825]
|
||||
- [0.57523, 0.57523, 0.57523]
|
||||
- [0.59222, 0.59222, 0.59222]
|
||||
- [0.60921, 0.60921, 0.60921]
|
||||
- [0.6262, 0.6262, 0.6262]
|
||||
- [0.6432, 0.6432, 0.6432]
|
||||
- [0.6602, 0.6602, 0.6602]
|
||||
- [0.67721, 0.67721, 0.67721]
|
||||
- [0.69422, 0.69422, 0.69422]
|
||||
- [0.71123, 0.71123, 0.71123]
|
||||
- [0.72824, 0.72824, 0.72824]
|
||||
- [0.74525, 0.74525, 0.74525]
|
||||
- [0.76226, 0.76226, 0.76226]
|
||||
- [0.77928, 0.77928, 0.77928]
|
||||
- [0.79629, 0.79629, 0.79629]
|
||||
- [0.81331, 0.81331, 0.81331]
|
||||
- [0.83033, 0.83033, 0.83033]
|
||||
- [0.84735, 0.84735, 0.84735]
|
||||
- [0.86436, 0.86436, 0.86436]
|
||||
- [0.88138, 0.88138, 0.88138]
|
||||
- [0.8984, 0.8984, 0.8984]
|
||||
- [0.91542, 0.91542, 0.91542]
|
||||
- [0.93244, 0.93244, 0.93244]
|
||||
- [0.94945, 0.94945, 0.94945]
|
||||
- [0.96647, 0.96647, 0.96647]
|
||||
- [0.98349, 0.98349, 0.98349]
|
||||
- [1.0005, 1.0005, 1.0005]
|
||||
- [1.0175, 1.0175, 1.0175]
|
||||
- [1.0346, 1.0346, 1.0346]
|
||||
- [1.0516, 1.0516, 1.0516]
|
||||
- [1.0686, 1.0686, 1.0686]
|
||||
- [1.0856, 1.0856, 1.0856]
|
||||
- [1.1026, 1.1026, 1.1026]
|
||||
- [1.1196, 1.1196, 1.1196]
|
||||
- [1.1367, 1.1367, 1.1367]
|
||||
- [1.1537, 1.1537, 1.1537]
|
||||
- [1.1707, 1.1707, 1.1707]
|
||||
- [1.1877, 1.1877, 1.1877]
|
||||
- [1.2047, 1.2047, 1.2047]
|
||||
- [1.2218, 1.2218, 1.2218]
|
||||
- [1.2388, 1.2388, 1.2388]
|
||||
- [1.2558, 1.2558, 1.2558]
|
||||
- [1.2728, 1.2728, 1.2728]
|
||||
- [1.2898, 1.2898, 1.2898]
|
||||
- [1.3069, 1.3069, 1.3069]
|
||||
- [1.3239, 1.3239, 1.3239]
|
||||
- [1.3409, 1.3409, 1.3409]
|
||||
- [1.3579, 1.3579, 1.3579]
|
||||
- [1.3749, 1.3749, 1.3749]
|
||||
- [1.392, 1.392, 1.392]
|
||||
- [1.409, 1.409, 1.409]
|
||||
- [1.426, 1.426, 1.426]
|
||||
- [1.443, 1.443, 1.443]
|
||||
- [1.46, 1.46, 1.46]
|
||||
- [1.477, 1.477, 1.477]
|
||||
- [1.4941, 1.4941, 1.4941]
|
||||
- [1.5111, 1.5111, 1.5111]
|
||||
- [1.5281, 1.5281, 1.5281]
|
||||
- [1.5451, 1.5451, 1.5451]
|
||||
- [1.5621, 1.5621, 1.5621]
|
||||
- [1.5791, 1.5791, 1.5791]
|
||||
- [1.5962, 1.5962, 1.5962]
|
||||
- [1.6132, 1.6132, 1.6132]
|
||||
- [1.6302, 1.6302, 1.6302]
|
||||
- [1.6472, 1.6472, 1.6472]
|
||||
- [1.6642, 1.6642, 1.6642]
|
||||
- [1.6812, 1.6812, 1.6812]
|
||||
- [1.6982, 1.6982, 1.6982]
|
||||
- [1.7153, 1.7153, 1.7153]
|
||||
- [1.7323, 1.7323, 1.7323]
|
||||
- [1.7493, 1.7493, 1.7493]
|
||||
- [1.7663, 1.7663, 1.7663]
|
||||
- [1.7833, 1.7833, 1.7833]
|
||||
- [1.8003, 1.8003, 1.8003]
|
||||
- [1.8173, 1.8173, 1.8173]
|
||||
- [1.8343, 1.8343, 1.8343]
|
||||
- [1.8512, 1.8512, 1.8512]
|
||||
- [1.8682, 1.8682, 1.8682]
|
||||
- [1.8852, 1.8852, 1.8852]
|
||||
- [1.9022, 1.9022, 1.9022]
|
||||
- [1.9191, 1.9191, 1.9191]
|
||||
- [1.9361, 1.9361, 1.9361]
|
||||
- [1.953, 1.953, 1.953]
|
||||
- [1.97, 1.97, 1.97]
|
||||
- [1.9869, 1.9869, 1.9869]
|
||||
- [2.0038, 2.0038, 2.0038]
|
||||
- [2.0207, 2.0207, 2.0207]
|
||||
- [2.0375, 2.0375, 2.0375]
|
||||
- [2.0544, 2.0544, 2.0544]
|
||||
- [2.0712, 2.0712, 2.0712]
|
||||
- [2.088, 2.088, 2.088]
|
||||
- [2.1047, 2.1047, 2.1047]
|
||||
- [2.1214, 2.1214, 2.1214]
|
||||
- [2.1381, 2.1381, 2.1381]
|
||||
- [2.1547, 2.1547, 2.1547]
|
||||
- [2.1713, 2.1713, 2.1713]
|
||||
- [2.1878, 2.1878, 2.1878]
|
||||
- [2.2042, 2.2042, 2.2042]
|
||||
- [2.2205, 2.2205, 2.2205]
|
||||
- [2.2367, 2.2367, 2.2367]
|
||||
- [2.2529, 2.2529, 2.2529]
|
||||
- [2.2688, 2.2688, 2.2688]
|
||||
- [2.2847, 2.2847, 2.2847]
|
||||
- [2.3003, 2.3003, 2.3003]
|
||||
- [2.3158, 2.3158, 2.3158]
|
||||
- [2.3311, 2.3311, 2.3311]
|
||||
- [2.3462, 2.3462, 2.3462]
|
||||
- [2.361, 2.361, 2.361]
|
||||
- [2.3755, 2.3755, 2.3755]
|
||||
- [2.3896, 2.3896, 2.3896]
|
||||
- [2.4035, 2.4035, 2.4035]
|
||||
- [2.417, 2.417, 2.417]
|
||||
- [2.43, 2.43, 2.43]
|
||||
- [2.4426, 2.4426, 2.4426]
|
||||
- [2.4547, 2.4547, 2.4547]
|
||||
- [2.4664, 2.4664, 2.4664]
|
||||
- [2.4775, 2.4775, 2.4775]
|
||||
- [2.488, 2.488, 2.488]
|
||||
- [2.498, 2.498, 2.498]
|
||||
- [2.5074, 2.5074, 2.5074]
|
||||
- [2.5162, 2.5162, 2.5162]
|
||||
- [2.5244, 2.5244, 2.5244]
|
||||
- [2.532, 2.532, 2.532]
|
||||
- [2.539, 2.539, 2.539]
|
||||
- [2.5455, 2.5455, 2.5455]
|
||||
- [2.5514, 2.5514, 2.5514]
|
||||
- [2.5567, 2.5567, 2.5567]
|
||||
- [2.5616, 2.5616, 2.5616]
|
||||
- [2.566, 2.566, 2.566]
|
||||
- [2.5699, 2.5699, 2.5699]
|
||||
- [2.5735, 2.5735, 2.5735]
|
||||
- [2.5766, 2.5766, 2.5766]
|
||||
- [2.5795, 2.5795, 2.5795]
|
||||
- [2.582, 2.582, 2.582]
|
||||
- [2.5842, 2.5842, 2.5842]
|
||||
- [2.5861, 2.5861, 2.5861]
|
||||
- [2.5879, 2.5879, 2.5879]
|
||||
- [2.5894, 2.5894, 2.5894]
|
||||
- [2.5907, 2.5907, 2.5907]
|
||||
- [2.5919, 2.5919, 2.5919]
|
||||
- [2.5929, 2.5929, 2.5929]
|
||||
- [2.5938, 2.5938, 2.5938]
|
||||
- [2.5946, 2.5946, 2.5946]
|
||||
- [2.5953, 2.5953, 2.5953]
|
||||
- [2.5959, 2.5959, 2.5959]
|
||||
- [2.5964, 2.5964, 2.5964]
|
||||
- [2.5969, 2.5969, 2.5969]
|
||||
- [2.5973, 2.5973, 2.5973]
|
||||
- [2.5977, 2.5977, 2.5977]
|
||||
- [2.598, 2.598, 2.598]
|
||||
- [2.5982, 2.5982, 2.5982]
|
||||
- [2.5985, 2.5985, 2.5985]
|
||||
- [2.5987, 2.5987, 2.5987]
|
||||
- [2.5988, 2.5988, 2.5988]
|
||||
@@ -1,460 +0,0 @@
|
||||
# Generated by tools/film-profiles/convert.py.
|
||||
#
|
||||
# *** CONSTRUCTED, NOT MEASURED. ***
|
||||
#
|
||||
# Ilford publish this film's spectral sensitivity and characteristic
|
||||
# curve as graphs, and no granularity figure at all, so this profile is
|
||||
# built rather than extracted. Its speed is the published ISO rating,
|
||||
# and its contrast is ISO 6:1993's normal development (average gradient
|
||||
# 0.62). Its spectral response is borrowed from Kodak Double-X, a
|
||||
# *measured* panchromatic negative, shifted by the speed difference.
|
||||
# Its granularity is an estimate, ordered against the other films here.
|
||||
#
|
||||
# So it renders as a film of this speed and contrast, not as a
|
||||
# measurement of this emulsion. Replace it outright if anyone digitises
|
||||
# the real curves.
|
||||
|
||||
version: 'constructed-1'
|
||||
stock: ilford_pan_f_plus
|
||||
name: 'Ilford Pan F Plus'
|
||||
kind: negative # negative | positive
|
||||
support: film # film | paper
|
||||
stage: filming # filming | printing
|
||||
monochrome: true
|
||||
target_print: kodak_2302
|
||||
reference_illuminant: D55
|
||||
viewing_illuminant: D50
|
||||
|
||||
rms_granularity: [5, 5, 5]
|
||||
|
||||
# log10 spectral sensitivity per layer, 380-780nm at 5nm.
|
||||
log_sensitivity:
|
||||
- [-9, -9, -9] # 380nm
|
||||
- [-9, -9, -9] # 385nm
|
||||
- [-9, -9, -9] # 390nm
|
||||
- [-9, -9, -9] # 395nm
|
||||
- [-9, -9, -9] # 400nm
|
||||
- [-9, -9, -9] # 405nm
|
||||
- [-9, -9, -9] # 410nm
|
||||
- [-9, -9, -9] # 415nm
|
||||
- [-9, -9, -9] # 420nm
|
||||
- [-1.41619, -1.41619, -1.41619] # 425nm
|
||||
- [-1.41593, -1.41593, -1.41593] # 430nm
|
||||
- [-1.41593, -1.41593, -1.41593] # 435nm
|
||||
- [-1.41628, -1.41628, -1.41628] # 440nm
|
||||
- [-1.42598, -1.42598, -1.42598] # 445nm
|
||||
- [-1.43871, -1.43871, -1.43871] # 450nm
|
||||
- [-1.4555, -1.4555, -1.4555] # 455nm
|
||||
- [-1.47476, -1.47476, -1.47476] # 460nm
|
||||
- [-1.49621, -1.49621, -1.49621] # 465nm
|
||||
- [-1.51464, -1.51464, -1.51464] # 470nm
|
||||
- [-1.54053, -1.54053, -1.54053] # 475nm
|
||||
- [-1.56831, -1.56831, -1.56831] # 480nm
|
||||
- [-1.60625, -1.60625, -1.60625] # 485nm
|
||||
- [-1.646, -1.646, -1.646] # 490nm
|
||||
- [-1.68568, -1.68568, -1.68568] # 495nm
|
||||
- [-1.71895, -1.71895, -1.71895] # 500nm
|
||||
- [-1.73423, -1.73423, -1.73423] # 505nm
|
||||
- [-1.7374, -1.7374, -1.7374] # 510nm
|
||||
- [-1.73669, -1.73669, -1.73669] # 515nm
|
||||
- [-1.72692, -1.72692, -1.72692] # 520nm
|
||||
- [-1.7139, -1.7139, -1.7139] # 525nm
|
||||
- [-1.69913, -1.69913, -1.69913] # 530nm
|
||||
- [-1.68117, -1.68117, -1.68117] # 535nm
|
||||
- [-1.66295, -1.66295, -1.66295] # 540nm
|
||||
- [-1.64408, -1.64408, -1.64408] # 545nm
|
||||
- [-1.62751, -1.62751, -1.62751] # 550nm
|
||||
- [-1.60974, -1.60974, -1.60974] # 555nm
|
||||
- [-1.59649, -1.59649, -1.59649] # 560nm
|
||||
- [-1.58908, -1.58908, -1.58908] # 565nm
|
||||
- [-1.5842, -1.5842, -1.5842] # 570nm
|
||||
- [-1.58064, -1.58064, -1.58064] # 575nm
|
||||
- [-1.58345, -1.58345, -1.58345] # 580nm
|
||||
- [-1.58988, -1.58988, -1.58988] # 585nm
|
||||
- [-1.5994, -1.5994, -1.5994] # 590nm
|
||||
- [-1.61204, -1.61204, -1.61204] # 595nm
|
||||
- [-1.62391, -1.62391, -1.62391] # 600nm
|
||||
- [-1.63908, -1.63908, -1.63908] # 605nm
|
||||
- [-1.65419, -1.65419, -1.65419] # 610nm
|
||||
- [-1.65922, -1.65922, -1.65922] # 615nm
|
||||
- [-1.65081, -1.65081, -1.65081] # 620nm
|
||||
- [-1.65978, -1.65978, -1.65978] # 625nm
|
||||
- [-1.68892, -1.68892, -1.68892] # 630nm
|
||||
- [-1.77928, -1.77928, -1.77928] # 635nm
|
||||
- [-1.9687, -1.9687, -1.9687] # 640nm
|
||||
- [-2.18434, -2.18434, -2.18434] # 645nm
|
||||
- [-2.43996, -2.43996, -2.43996] # 650nm
|
||||
- [-2.70182, -2.70182, -2.70182] # 655nm
|
||||
- [-2.96969, -2.96969, -2.96969] # 660nm
|
||||
- [-9, -9, -9] # 665nm
|
||||
- [-9, -9, -9] # 670nm
|
||||
- [-9, -9, -9] # 675nm
|
||||
- [-9, -9, -9] # 680nm
|
||||
- [-9, -9, -9] # 685nm
|
||||
- [-9, -9, -9] # 690nm
|
||||
- [-9, -9, -9] # 695nm
|
||||
- [-9, -9, -9] # 700nm
|
||||
- [-9, -9, -9] # 705nm
|
||||
- [-9, -9, -9] # 710nm
|
||||
- [-9, -9, -9] # 715nm
|
||||
- [-9, -9, -9] # 720nm
|
||||
- [-9, -9, -9] # 725nm
|
||||
- [-9, -9, -9] # 730nm
|
||||
- [-9, -9, -9] # 735nm
|
||||
- [-9, -9, -9] # 740nm
|
||||
- [-9, -9, -9] # 745nm
|
||||
- [-9, -9, -9] # 750nm
|
||||
- [-9, -9, -9] # 755nm
|
||||
- [-9, -9, -9] # 760nm
|
||||
- [-9, -9, -9] # 765nm
|
||||
- [-9, -9, -9] # 770nm
|
||||
- [-9, -9, -9] # 775nm
|
||||
- [-9, -9, -9] # 780nm
|
||||
|
||||
# Developed silver: neutral across the band.
|
||||
dye_density:
|
||||
- [0.333333, 0.333333, 0.333333] # 380nm
|
||||
- [0.333333, 0.333333, 0.333333] # 385nm
|
||||
- [0.333333, 0.333333, 0.333333] # 390nm
|
||||
- [0.333333, 0.333333, 0.333333] # 395nm
|
||||
- [0.333333, 0.333333, 0.333333] # 400nm
|
||||
- [0.333333, 0.333333, 0.333333] # 405nm
|
||||
- [0.333333, 0.333333, 0.333333] # 410nm
|
||||
- [0.333333, 0.333333, 0.333333] # 415nm
|
||||
- [0.333333, 0.333333, 0.333333] # 420nm
|
||||
- [0.333333, 0.333333, 0.333333] # 425nm
|
||||
- [0.333333, 0.333333, 0.333333] # 430nm
|
||||
- [0.333333, 0.333333, 0.333333] # 435nm
|
||||
- [0.333333, 0.333333, 0.333333] # 440nm
|
||||
- [0.333333, 0.333333, 0.333333] # 445nm
|
||||
- [0.333333, 0.333333, 0.333333] # 450nm
|
||||
- [0.333333, 0.333333, 0.333333] # 455nm
|
||||
- [0.333333, 0.333333, 0.333333] # 460nm
|
||||
- [0.333333, 0.333333, 0.333333] # 465nm
|
||||
- [0.333333, 0.333333, 0.333333] # 470nm
|
||||
- [0.333333, 0.333333, 0.333333] # 475nm
|
||||
- [0.333333, 0.333333, 0.333333] # 480nm
|
||||
- [0.333333, 0.333333, 0.333333] # 485nm
|
||||
- [0.333333, 0.333333, 0.333333] # 490nm
|
||||
- [0.333333, 0.333333, 0.333333] # 495nm
|
||||
- [0.333333, 0.333333, 0.333333] # 500nm
|
||||
- [0.333333, 0.333333, 0.333333] # 505nm
|
||||
- [0.333333, 0.333333, 0.333333] # 510nm
|
||||
- [0.333333, 0.333333, 0.333333] # 515nm
|
||||
- [0.333333, 0.333333, 0.333333] # 520nm
|
||||
- [0.333333, 0.333333, 0.333333] # 525nm
|
||||
- [0.333333, 0.333333, 0.333333] # 530nm
|
||||
- [0.333333, 0.333333, 0.333333] # 535nm
|
||||
- [0.333333, 0.333333, 0.333333] # 540nm
|
||||
- [0.333333, 0.333333, 0.333333] # 545nm
|
||||
- [0.333333, 0.333333, 0.333333] # 550nm
|
||||
- [0.333333, 0.333333, 0.333333] # 555nm
|
||||
- [0.333333, 0.333333, 0.333333] # 560nm
|
||||
- [0.333333, 0.333333, 0.333333] # 565nm
|
||||
- [0.333333, 0.333333, 0.333333] # 570nm
|
||||
- [0.333333, 0.333333, 0.333333] # 575nm
|
||||
- [0.333333, 0.333333, 0.333333] # 580nm
|
||||
- [0.333333, 0.333333, 0.333333] # 585nm
|
||||
- [0.333333, 0.333333, 0.333333] # 590nm
|
||||
- [0.333333, 0.333333, 0.333333] # 595nm
|
||||
- [0.333333, 0.333333, 0.333333] # 600nm
|
||||
- [0.333333, 0.333333, 0.333333] # 605nm
|
||||
- [0.333333, 0.333333, 0.333333] # 610nm
|
||||
- [0.333333, 0.333333, 0.333333] # 615nm
|
||||
- [0.333333, 0.333333, 0.333333] # 620nm
|
||||
- [0.333333, 0.333333, 0.333333] # 625nm
|
||||
- [0.333333, 0.333333, 0.333333] # 630nm
|
||||
- [0.333333, 0.333333, 0.333333] # 635nm
|
||||
- [0.333333, 0.333333, 0.333333] # 640nm
|
||||
- [0.333333, 0.333333, 0.333333] # 645nm
|
||||
- [0.333333, 0.333333, 0.333333] # 650nm
|
||||
- [0.333333, 0.333333, 0.333333] # 655nm
|
||||
- [0.333333, 0.333333, 0.333333] # 660nm
|
||||
- [0.333333, 0.333333, 0.333333] # 665nm
|
||||
- [0.333333, 0.333333, 0.333333] # 670nm
|
||||
- [0.333333, 0.333333, 0.333333] # 675nm
|
||||
- [0.333333, 0.333333, 0.333333] # 680nm
|
||||
- [0.333333, 0.333333, 0.333333] # 685nm
|
||||
- [0.333333, 0.333333, 0.333333] # 690nm
|
||||
- [0.333333, 0.333333, 0.333333] # 695nm
|
||||
- [0.333333, 0.333333, 0.333333] # 700nm
|
||||
- [0.333333, 0.333333, 0.333333] # 705nm
|
||||
- [0.333333, 0.333333, 0.333333] # 710nm
|
||||
- [0.333333, 0.333333, 0.333333] # 715nm
|
||||
- [0.333333, 0.333333, 0.333333] # 720nm
|
||||
- [0.333333, 0.333333, 0.333333] # 725nm
|
||||
- [0.333333, 0.333333, 0.333333] # 730nm
|
||||
- [0.333333, 0.333333, 0.333333] # 735nm
|
||||
- [0.333333, 0.333333, 0.333333] # 740nm
|
||||
- [0.333333, 0.333333, 0.333333] # 745nm
|
||||
- [0.333333, 0.333333, 0.333333] # 750nm
|
||||
- [0.333333, 0.333333, 0.333333] # 755nm
|
||||
- [0.333333, 0.333333, 0.333333] # 760nm
|
||||
- [0.333333, 0.333333, 0.333333] # 765nm
|
||||
- [0.333333, 0.333333, 0.333333] # 770nm
|
||||
- [0.333333, 0.333333, 0.333333] # 775nm
|
||||
- [0.333333, 0.333333, 0.333333] # 780nm
|
||||
|
||||
# Base plus fog. No orange mask on a black-and-white film.
|
||||
base_density: [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]
|
||||
|
||||
# The characteristic curve, parametric.
|
||||
log_exposure_min: -3
|
||||
log_exposure_max: 4
|
||||
density_curves:
|
||||
- [3.5129e-07, 3.5129e-07, 3.5129e-07]
|
||||
- [4.2083e-07, 4.2083e-07, 4.2083e-07]
|
||||
- [5.0412e-07, 5.0412e-07, 5.0412e-07]
|
||||
- [6.039e-07, 6.039e-07, 6.039e-07]
|
||||
- [7.2343e-07, 7.2343e-07, 7.2343e-07]
|
||||
- [8.6662e-07, 8.6662e-07, 8.6662e-07]
|
||||
- [1.0381e-06, 1.0381e-06, 1.0381e-06]
|
||||
- [1.2436e-06, 1.2436e-06, 1.2436e-06]
|
||||
- [1.4898e-06, 1.4898e-06, 1.4898e-06]
|
||||
- [1.7846e-06, 1.7846e-06, 1.7846e-06]
|
||||
- [2.1379e-06, 2.1379e-06, 2.1379e-06]
|
||||
- [2.561e-06, 2.561e-06, 2.561e-06]
|
||||
- [3.0679e-06, 3.0679e-06, 3.0679e-06]
|
||||
- [3.6751e-06, 3.6751e-06, 3.6751e-06]
|
||||
- [4.4025e-06, 4.4025e-06, 4.4025e-06]
|
||||
- [5.2739e-06, 5.2739e-06, 5.2739e-06]
|
||||
- [6.3177e-06, 6.3177e-06, 6.3177e-06]
|
||||
- [7.5681e-06, 7.5681e-06, 7.5681e-06]
|
||||
- [9.066e-06, 9.066e-06, 9.066e-06]
|
||||
- [1.086e-05, 1.086e-05, 1.086e-05]
|
||||
- [1.301e-05, 1.301e-05, 1.301e-05]
|
||||
- [1.5585e-05, 1.5585e-05, 1.5585e-05]
|
||||
- [1.8669e-05, 1.8669e-05, 1.8669e-05]
|
||||
- [2.2364e-05, 2.2364e-05, 2.2364e-05]
|
||||
- [2.6789e-05, 2.6789e-05, 2.6789e-05]
|
||||
- [3.2091e-05, 3.2091e-05, 3.2091e-05]
|
||||
- [3.8441e-05, 3.8441e-05, 3.8441e-05]
|
||||
- [4.6048e-05, 4.6048e-05, 4.6048e-05]
|
||||
- [5.516e-05, 5.516e-05, 5.516e-05]
|
||||
- [6.6074e-05, 6.6074e-05, 6.6074e-05]
|
||||
- [7.9146e-05, 7.9146e-05, 7.9146e-05]
|
||||
- [9.4804e-05, 9.4804e-05, 9.4804e-05]
|
||||
- [0.00011356, 0.00011356, 0.00011356]
|
||||
- [0.00013602, 0.00013602, 0.00013602]
|
||||
- [0.00016292, 0.00016292, 0.00016292]
|
||||
- [0.00019513, 0.00019513, 0.00019513]
|
||||
- [0.0002337, 0.0002337, 0.0002337]
|
||||
- [0.00027989, 0.00027989, 0.00027989]
|
||||
- [0.00033519, 0.00033519, 0.00033519]
|
||||
- [0.00040139, 0.00040139, 0.00040139]
|
||||
- [0.00048064, 0.00048064, 0.00048064]
|
||||
- [0.00057548, 0.00057548, 0.00057548]
|
||||
- [0.00068897, 0.00068897, 0.00068897]
|
||||
- [0.00082475, 0.00082475, 0.00082475]
|
||||
- [0.00098713, 0.00098713, 0.00098713]
|
||||
- [0.0011813, 0.0011813, 0.0011813]
|
||||
- [0.0014134, 0.0014134, 0.0014134]
|
||||
- [0.0016906, 0.0016906, 0.0016906]
|
||||
- [0.0020217, 0.0020217, 0.0020217]
|
||||
- [0.0024167, 0.0024167, 0.0024167]
|
||||
- [0.0028878, 0.0028878, 0.0028878]
|
||||
- [0.0034491, 0.0034491, 0.0034491]
|
||||
- [0.004117, 0.004117, 0.004117]
|
||||
- [0.004911, 0.004911, 0.004911]
|
||||
- [0.0058534, 0.0058534, 0.0058534]
|
||||
- [0.0069701, 0.0069701, 0.0069701]
|
||||
- [0.0082906, 0.0082906, 0.0082906]
|
||||
- [0.0098485, 0.0098485, 0.0098485]
|
||||
- [0.011681, 0.011681, 0.011681]
|
||||
- [0.013831, 0.013831, 0.013831]
|
||||
- [0.016344, 0.016344, 0.016344]
|
||||
- [0.019268, 0.019268, 0.019268]
|
||||
- [0.022655, 0.022655, 0.022655]
|
||||
- [0.026559, 0.026559, 0.026559]
|
||||
- [0.031032, 0.031032, 0.031032]
|
||||
- [0.036126, 0.036126, 0.036126]
|
||||
- [0.041886, 0.041886, 0.041886]
|
||||
- [0.048352, 0.048352, 0.048352]
|
||||
- [0.055556, 0.055556, 0.055556]
|
||||
- [0.063518, 0.063518, 0.063518]
|
||||
- [0.072247, 0.072247, 0.072247]
|
||||
- [0.081739, 0.081739, 0.081739]
|
||||
- [0.09198, 0.09198, 0.09198]
|
||||
- [0.10294, 0.10294, 0.10294]
|
||||
- [0.11459, 0.11459, 0.11459]
|
||||
- [0.12687, 0.12687, 0.12687]
|
||||
- [0.13975, 0.13975, 0.13975]
|
||||
- [0.15317, 0.15317, 0.15317]
|
||||
- [0.16707, 0.16707, 0.16707]
|
||||
- [0.18141, 0.18141, 0.18141]
|
||||
- [0.19613, 0.19613, 0.19613]
|
||||
- [0.21119, 0.21119, 0.21119]
|
||||
- [0.22653, 0.22653, 0.22653]
|
||||
- [0.24214, 0.24214, 0.24214]
|
||||
- [0.25796, 0.25796, 0.25796]
|
||||
- [0.27396, 0.27396, 0.27396]
|
||||
- [0.29013, 0.29013, 0.29013]
|
||||
- [0.30643, 0.30643, 0.30643]
|
||||
- [0.32284, 0.32284, 0.32284]
|
||||
- [0.33935, 0.33935, 0.33935]
|
||||
- [0.35595, 0.35595, 0.35595]
|
||||
- [0.37261, 0.37261, 0.37261]
|
||||
- [0.38933, 0.38933, 0.38933]
|
||||
- [0.4061, 0.4061, 0.4061]
|
||||
- [0.42291, 0.42291, 0.42291]
|
||||
- [0.43975, 0.43975, 0.43975]
|
||||
- [0.45663, 0.45663, 0.45663]
|
||||
- [0.47352, 0.47352, 0.47352]
|
||||
- [0.49044, 0.49044, 0.49044]
|
||||
- [0.50737, 0.50737, 0.50737]
|
||||
- [0.52432, 0.52432, 0.52432]
|
||||
- [0.54128, 0.54128, 0.54128]
|
||||
- [0.55825, 0.55825, 0.55825]
|
||||
- [0.57523, 0.57523, 0.57523]
|
||||
- [0.59222, 0.59222, 0.59222]
|
||||
- [0.60921, 0.60921, 0.60921]
|
||||
- [0.6262, 0.6262, 0.6262]
|
||||
- [0.6432, 0.6432, 0.6432]
|
||||
- [0.6602, 0.6602, 0.6602]
|
||||
- [0.67721, 0.67721, 0.67721]
|
||||
- [0.69422, 0.69422, 0.69422]
|
||||
- [0.71123, 0.71123, 0.71123]
|
||||
- [0.72824, 0.72824, 0.72824]
|
||||
- [0.74525, 0.74525, 0.74525]
|
||||
- [0.76226, 0.76226, 0.76226]
|
||||
- [0.77928, 0.77928, 0.77928]
|
||||
- [0.79629, 0.79629, 0.79629]
|
||||
- [0.81331, 0.81331, 0.81331]
|
||||
- [0.83033, 0.83033, 0.83033]
|
||||
- [0.84734, 0.84734, 0.84734]
|
||||
- [0.86436, 0.86436, 0.86436]
|
||||
- [0.88138, 0.88138, 0.88138]
|
||||
- [0.8984, 0.8984, 0.8984]
|
||||
- [0.91542, 0.91542, 0.91542]
|
||||
- [0.93244, 0.93244, 0.93244]
|
||||
- [0.94945, 0.94945, 0.94945]
|
||||
- [0.96647, 0.96647, 0.96647]
|
||||
- [0.98349, 0.98349, 0.98349]
|
||||
- [1.0005, 1.0005, 1.0005]
|
||||
- [1.0175, 1.0175, 1.0175]
|
||||
- [1.0345, 1.0345, 1.0345]
|
||||
- [1.0516, 1.0516, 1.0516]
|
||||
- [1.0686, 1.0686, 1.0686]
|
||||
- [1.0856, 1.0856, 1.0856]
|
||||
- [1.1026, 1.1026, 1.1026]
|
||||
- [1.1196, 1.1196, 1.1196]
|
||||
- [1.1367, 1.1367, 1.1367]
|
||||
- [1.1537, 1.1537, 1.1537]
|
||||
- [1.1707, 1.1707, 1.1707]
|
||||
- [1.1877, 1.1877, 1.1877]
|
||||
- [1.2047, 1.2047, 1.2047]
|
||||
- [1.2218, 1.2218, 1.2218]
|
||||
- [1.2388, 1.2388, 1.2388]
|
||||
- [1.2558, 1.2558, 1.2558]
|
||||
- [1.2728, 1.2728, 1.2728]
|
||||
- [1.2898, 1.2898, 1.2898]
|
||||
- [1.3069, 1.3069, 1.3069]
|
||||
- [1.3239, 1.3239, 1.3239]
|
||||
- [1.3409, 1.3409, 1.3409]
|
||||
- [1.3579, 1.3579, 1.3579]
|
||||
- [1.3749, 1.3749, 1.3749]
|
||||
- [1.3919, 1.3919, 1.3919]
|
||||
- [1.409, 1.409, 1.409]
|
||||
- [1.426, 1.426, 1.426]
|
||||
- [1.443, 1.443, 1.443]
|
||||
- [1.46, 1.46, 1.46]
|
||||
- [1.477, 1.477, 1.477]
|
||||
- [1.494, 1.494, 1.494]
|
||||
- [1.5111, 1.5111, 1.5111]
|
||||
- [1.5281, 1.5281, 1.5281]
|
||||
- [1.5451, 1.5451, 1.5451]
|
||||
- [1.5621, 1.5621, 1.5621]
|
||||
- [1.5791, 1.5791, 1.5791]
|
||||
- [1.5961, 1.5961, 1.5961]
|
||||
- [1.6131, 1.6131, 1.6131]
|
||||
- [1.6301, 1.6301, 1.6301]
|
||||
- [1.6471, 1.6471, 1.6471]
|
||||
- [1.6642, 1.6642, 1.6642]
|
||||
- [1.6812, 1.6812, 1.6812]
|
||||
- [1.6982, 1.6982, 1.6982]
|
||||
- [1.7151, 1.7151, 1.7151]
|
||||
- [1.7321, 1.7321, 1.7321]
|
||||
- [1.7491, 1.7491, 1.7491]
|
||||
- [1.7661, 1.7661, 1.7661]
|
||||
- [1.7831, 1.7831, 1.7831]
|
||||
- [1.8001, 1.8001, 1.8001]
|
||||
- [1.817, 1.817, 1.817]
|
||||
- [1.834, 1.834, 1.834]
|
||||
- [1.8509, 1.8509, 1.8509]
|
||||
- [1.8679, 1.8679, 1.8679]
|
||||
- [1.8848, 1.8848, 1.8848]
|
||||
- [1.9017, 1.9017, 1.9017]
|
||||
- [1.9186, 1.9186, 1.9186]
|
||||
- [1.9354, 1.9354, 1.9354]
|
||||
- [1.9523, 1.9523, 1.9523]
|
||||
- [1.9691, 1.9691, 1.9691]
|
||||
- [1.9859, 1.9859, 1.9859]
|
||||
- [2.0027, 2.0027, 2.0027]
|
||||
- [2.0194, 2.0194, 2.0194]
|
||||
- [2.036, 2.036, 2.036]
|
||||
- [2.0527, 2.0527, 2.0527]
|
||||
- [2.0692, 2.0692, 2.0692]
|
||||
- [2.0857, 2.0857, 2.0857]
|
||||
- [2.1022, 2.1022, 2.1022]
|
||||
- [2.1185, 2.1185, 2.1185]
|
||||
- [2.1347, 2.1347, 2.1347]
|
||||
- [2.1509, 2.1509, 2.1509]
|
||||
- [2.1669, 2.1669, 2.1669]
|
||||
- [2.1827, 2.1827, 2.1827]
|
||||
- [2.1984, 2.1984, 2.1984]
|
||||
- [2.2139, 2.2139, 2.2139]
|
||||
- [2.2292, 2.2292, 2.2292]
|
||||
- [2.2443, 2.2443, 2.2443]
|
||||
- [2.2591, 2.2591, 2.2591]
|
||||
- [2.2737, 2.2737, 2.2737]
|
||||
- [2.2879, 2.2879, 2.2879]
|
||||
- [2.3018, 2.3018, 2.3018]
|
||||
- [2.3153, 2.3153, 2.3153]
|
||||
- [2.3284, 2.3284, 2.3284]
|
||||
- [2.3411, 2.3411, 2.3411]
|
||||
- [2.3533, 2.3533, 2.3533]
|
||||
- [2.365, 2.365, 2.365]
|
||||
- [2.3761, 2.3761, 2.3761]
|
||||
- [2.3867, 2.3867, 2.3867]
|
||||
- [2.3968, 2.3968, 2.3968]
|
||||
- [2.4063, 2.4063, 2.4063]
|
||||
- [2.4151, 2.4151, 2.4151]
|
||||
- [2.4234, 2.4234, 2.4234]
|
||||
- [2.4311, 2.4311, 2.4311]
|
||||
- [2.4382, 2.4382, 2.4382]
|
||||
- [2.4447, 2.4447, 2.4447]
|
||||
- [2.4507, 2.4507, 2.4507]
|
||||
- [2.4561, 2.4561, 2.4561]
|
||||
- [2.461, 2.461, 2.461]
|
||||
- [2.4655, 2.4655, 2.4655]
|
||||
- [2.4695, 2.4695, 2.4695]
|
||||
- [2.4731, 2.4731, 2.4731]
|
||||
- [2.4763, 2.4763, 2.4763]
|
||||
- [2.4791, 2.4791, 2.4791]
|
||||
- [2.4817, 2.4817, 2.4817]
|
||||
- [2.4839, 2.4839, 2.4839]
|
||||
- [2.4859, 2.4859, 2.4859]
|
||||
- [2.4877, 2.4877, 2.4877]
|
||||
- [2.4892, 2.4892, 2.4892]
|
||||
- [2.4906, 2.4906, 2.4906]
|
||||
- [2.4918, 2.4918, 2.4918]
|
||||
- [2.4928, 2.4928, 2.4928]
|
||||
- [2.4937, 2.4937, 2.4937]
|
||||
- [2.4945, 2.4945, 2.4945]
|
||||
- [2.4952, 2.4952, 2.4952]
|
||||
- [2.4958, 2.4958, 2.4958]
|
||||
- [2.4964, 2.4964, 2.4964]
|
||||
- [2.4969, 2.4969, 2.4969]
|
||||
- [2.4973, 2.4973, 2.4973]
|
||||
- [2.4976, 2.4976, 2.4976]
|
||||
- [2.4979, 2.4979, 2.4979]
|
||||
- [2.4982, 2.4982, 2.4982]
|
||||
- [2.4984, 2.4984, 2.4984]
|
||||
- [2.4986, 2.4986, 2.4986]
|
||||
- [2.4988, 2.4988, 2.4988]
|
||||
- [2.499, 2.499, 2.499]
|
||||
- [2.4991, 2.4991, 2.4991]
|
||||
- [2.4992, 2.4992, 2.4992]
|
||||
- [2.4993, 2.4993, 2.4993]
|
||||
- [2.4994, 2.4994, 2.4994]
|
||||
- [2.4995, 2.4995, 2.4995]
|
||||
@@ -1,452 +0,0 @@
|
||||
# Generated by tools/film-profiles/convert.py from spektrafilm.
|
||||
# Do not edit by hand: re-run the converter instead.
|
||||
#
|
||||
# spektrafilm by Andrea Volpato, https://github.com/andreavolpato/spektrafilm
|
||||
# Licensed CC BY-SA 4.0. Modified for DarkRoom: trimmed to the fields the
|
||||
# renderer uses and reformatted; see profiles/CHANGELOG.txt.
|
||||
|
||||
version: '0.3.2'
|
||||
stock: kodak_2383
|
||||
name: 'Kodak Vision 2383'
|
||||
kind: negative # negative | positive
|
||||
support: film # film | paper
|
||||
stage: printing # filming | printing
|
||||
monochrome: false
|
||||
reference_illuminant: TH-KG3
|
||||
viewing_illuminant: K75P
|
||||
|
||||
# log10 spectral sensitivity per layer, 380-780nm at 5nm, in R,G,B layer
|
||||
# order. A null upstream means the datasheet has no reading there, which is
|
||||
# blindness, so it is written as the sentinel the loader reads as such.
|
||||
log_sensitivity:
|
||||
- [-0.883808, 0.150828, 0.5583] # 380nm
|
||||
- [-0.207411, -0.78523, 0.487229] # 385nm
|
||||
- [-0.361328, -1.86435, 0.412157] # 390nm
|
||||
- [-1.18339, -2.33066, 0.348404] # 395nm
|
||||
- [-2.18923, -2.77101, 0.309857] # 400nm
|
||||
- [-2.3357, -3.17019, 0.326938] # 405nm
|
||||
- [-2.49276, -3.50597, 0.375358] # 410nm
|
||||
- [-2.65879, -3.75612, 0.450451] # 415nm
|
||||
- [-2.83218, -3.89841, 0.534901] # 420nm
|
||||
- [-3.01134, -3.91061, 0.634267] # 425nm
|
||||
- [-3.19465, -3.77049, 0.737682] # 430nm
|
||||
- [-3.38049, -3.45581, 0.792506] # 435nm
|
||||
- [-3.56727, -2.94436, 0.846104] # 440nm
|
||||
- [-3.75338, -2.21388, 0.927953] # 445nm
|
||||
- [-3.9372, -0.692207, 1.01962] # 450nm
|
||||
- [-4.11713, -0.558681, 1.14188] # 455nm
|
||||
- [-4.29155, -0.502874, 1.24899] # 460nm
|
||||
- [-4.45887, -0.427225, 1.29287] # 465nm
|
||||
- [-4.61747, -0.3508, 1.26857] # 470nm
|
||||
- [-4.76574, -0.282084, 1.04607] # 475nm
|
||||
- [-4.90208, -0.21023, 0.692538] # 480nm
|
||||
- [-5.02488, -0.118926, 0.210161] # 485nm
|
||||
- [-5.13252, -0.022579, -0.296747] # 490nm
|
||||
- [-5.2234, 0.0498084, -0.764108] # 495nm
|
||||
- [-5.29591, 0.107209, -1.23077] # 500nm
|
||||
- [-5.34845, 0.149272, -1.78664] # 505nm
|
||||
- [-5.3794, 0.173934, -2.31584] # 510nm
|
||||
- [-5.38716, 0.179172, -2.80583] # 515nm
|
||||
- [-5.37011, 0.184326, -3.26083] # 520nm
|
||||
- [-5.32665, 0.208962, -3.68445] # 525nm
|
||||
- [-5.25517, 0.260557, -4.07982] # 530nm
|
||||
- [-5.15407, 0.354333, -4.44969] # 535nm
|
||||
- [-5.02172, 0.501374, -4.79644] # 540nm
|
||||
- [-4.85653, 0.771704, -5.12218] # 545nm
|
||||
- [-4.65689, 0.916834, -5.42875] # 550nm
|
||||
- [-4.42119, 0.677765, -5.71781] # 555nm
|
||||
- [-4.14781, 0.354352, -5.99081] # 560nm
|
||||
- [-3.83516, 0.101789, -6.24905] # 565nm
|
||||
- [-3.48162, -0.16131, -6.4937] # 570nm
|
||||
- [-3.08558, -0.418834, -6.7258] # 575nm
|
||||
- [-2.64544, -0.666563, -6.9463] # 580nm
|
||||
- [-2.14045, -1.01837, -7.15604] # 585nm
|
||||
- [-1.65561, -1.35887, -7.3558] # 590nm
|
||||
- [-1.58929, -1.68353, -7.54626] # 595nm
|
||||
- [-1.563, -1.99343, -7.72807] # 600nm
|
||||
- [-1.4964, -2.28956, -7.90179] # 605nm
|
||||
- [-1.41382, -2.57282, -8.06797] # 610nm
|
||||
- [-1.32759, -2.84402, -8.22707] # 615nm
|
||||
- [-1.23505, -3.10392, -8.37954] # 620nm
|
||||
- [-1.14185, -3.35322, -8.52579] # 625nm
|
||||
- [-1.06632, -3.59254, -8.66619] # 630nm
|
||||
- [-1.01884, -3.82247, -8.80108] # 635nm
|
||||
- [-0.989966, -4.04357, -8.93078] # 640nm
|
||||
- [-0.980042, -4.25631, -9.05559] # 645nm
|
||||
- [-0.96699, -4.46118, -9.17578] # 650nm
|
||||
- [-0.926413, -4.6586, -9.2916] # 655nm
|
||||
- [-0.866449, -4.84897, -9.40328] # 660nm
|
||||
- [-0.760525, -5.03266, -9.51104] # 665nm
|
||||
- [-0.645559, -5.21002, -9.61509] # 670nm
|
||||
- [-0.535662, -5.38136, -9.71561] # 675nm
|
||||
- [-0.430857, -5.54699, -9.81278] # 680nm
|
||||
- [-0.347318, -5.7072, -9.90676] # 685nm
|
||||
- [-0.295084, -5.86223, -9.99771] # 690nm
|
||||
- [-0.290194, -6.01234, -10.0858] # 695nm
|
||||
- [-0.330919, -6.15776, -10.1711] # 700nm
|
||||
- [-0.457022, -6.29871, -10.2538] # 705nm
|
||||
- [-0.625307, -6.43538, -10.334] # 710nm
|
||||
- [-0.811931, -6.56798, -10.4117] # 715nm
|
||||
- [-1.10502, -6.69668, -10.4872] # 720nm
|
||||
- [-1.60889, -6.82164, -10.5606] # 725nm
|
||||
- [-2.10202, -6.94304, -10.6318] # 730nm
|
||||
- [-2.58107, -7.06101, -10.701] # 735nm
|
||||
- [-3.04662, -7.17571, -10.7683] # 740nm
|
||||
- [-3.49924, -7.28727, -10.8337] # 745nm
|
||||
- [-3.93946, -7.39581, -10.8974] # 750nm
|
||||
- [-4.36779, -7.50145, -10.9594] # 755nm
|
||||
- [-4.78469, -7.60432, -11.0197] # 760nm
|
||||
- [-5.19062, -7.70451, -11.0785] # 765nm
|
||||
- [-5.586, -7.80214, -11.1358] # 770nm
|
||||
- [-5.97125, -7.89729, -11.1916] # 775nm
|
||||
- [-6.34674, -7.99007, -11.246] # 780nm
|
||||
|
||||
# Spectral density of each layer's dye at unit density, same grid and order.
|
||||
dye_density:
|
||||
- [0.328986, 0.0494454, 0.299156] # 380nm
|
||||
- [0.336504, 0.0510529, 0.343005] # 385nm
|
||||
- [0.3402, 0.0555116, 0.406073] # 390nm
|
||||
- [0.336466, 0.0651289, 0.482104] # 395nm
|
||||
- [0.32656, 0.0750952, 0.563436] # 400nm
|
||||
- [0.309699, 0.0829418, 0.655731] # 405nm
|
||||
- [0.286499, 0.0890979, 0.752916] # 410nm
|
||||
- [0.255415, 0.0943034, 0.850953] # 415nm
|
||||
- [0.221951, 0.100645, 0.949009] # 420nm
|
||||
- [0.189029, 0.111732, 1.03685] # 425nm
|
||||
- [0.159868, 0.119186, 1.10663] # 430nm
|
||||
- [0.132417, 0.10616, 1.15544] # 435nm
|
||||
- [0.109273, 0.0910754, 1.18289] # 440nm
|
||||
- [0.0915387, 0.0883877, 1.19449] # 445nm
|
||||
- [0.0737649, 0.0954276, 1.18856] # 450nm
|
||||
- [0.0617125, 0.112444, 1.16634] # 455nm
|
||||
- [0.0518078, 0.138253, 1.12038] # 460nm
|
||||
- [0.0439813, 0.167942, 1.05639] # 465nm
|
||||
- [0.0392137, 0.209864, 0.97062] # 470nm
|
||||
- [0.0368973, 0.259982, 0.875373] # 475nm
|
||||
- [0.0355868, 0.321888, 0.771241] # 480nm
|
||||
- [0.0345324, 0.388751, 0.663754] # 485nm
|
||||
- [0.0344955, 0.469372, 0.562666] # 490nm
|
||||
- [0.0355061, 0.556036, 0.465867] # 495nm
|
||||
- [0.0366856, 0.650234, 0.387591] # 500nm
|
||||
- [0.0379067, 0.742187, 0.31118] # 505nm
|
||||
- [0.0413467, 0.826601, 0.247904] # 510nm
|
||||
- [0.0481787, 0.903731, 0.198016] # 515nm
|
||||
- [0.057178, 0.964939, 0.159676] # 520nm
|
||||
- [0.0681133, 1.01623, 0.129016] # 525nm
|
||||
- [0.0803422, 1.05817, 0.106182] # 530nm
|
||||
- [0.10028, 1.09675, 0.0884244] # 535nm
|
||||
- [0.121123, 1.11559, 0.0763817] # 540nm
|
||||
- [0.146407, 1.11349, 0.0674767] # 545nm
|
||||
- [0.174623, 1.09034, 0.0604038] # 550nm
|
||||
- [0.20452, 1.03979, 0.0543507] # 555nm
|
||||
- [0.243368, 0.969812, 0.0486648] # 560nm
|
||||
- [0.286176, 0.87937, 0.0434019] # 565nm
|
||||
- [0.335617, 0.776654, 0.0383795] # 570nm
|
||||
- [0.388536, 0.668154, 0.0338406] # 575nm
|
||||
- [0.445432, 0.562247, 0.0296466] # 580nm
|
||||
- [0.509302, 0.465045, 0.0265871] # 585nm
|
||||
- [0.573817, 0.37766, 0.0235633] # 590nm
|
||||
- [0.64277, 0.310708, 0.0208485] # 595nm
|
||||
- [0.713472, 0.251217, 0.0180477] # 600nm
|
||||
- [0.782746, 0.203133, 0.0165478] # 605nm
|
||||
- [0.851817, 0.16503, 0.0149352] # 610nm
|
||||
- [0.921828, 0.138575, 0.0147504] # 615nm
|
||||
- [0.985922, 0.115047, 0.0147504] # 620nm
|
||||
- [1.04479, 0.0961419, 0.0147504] # 625nm
|
||||
- [1.09745, 0.0811408, 0.0148558] # 630nm
|
||||
- [1.14188, 0.0685567, 0.0158056] # 635nm
|
||||
- [1.1788, 0.05962, 0.016592] # 640nm
|
||||
- [1.21155, 0.0521869, 0.0178857] # 645nm
|
||||
- [1.23287, 0.0458328, 0.0192774] # 650nm
|
||||
- [1.24562, 0.0408726, 0.0197619] # 655nm
|
||||
- [1.25044, 0.0360851, 0.0197971] # 660nm
|
||||
- [1.24882, 0.0314533, 0.0212521] # 665nm
|
||||
- [1.23925, 0.0277924, 0.0221059] # 670nm
|
||||
- [1.21968, 0.0247973, 0.0226475] # 675nm
|
||||
- [1.18839, 0.0220063, 0.0221877] # 680nm
|
||||
- [1.14956, 0.0175037, 0.0201579] # 685nm
|
||||
- [1.10637, 0.0151801, 0.0178864] # 690nm
|
||||
- [1.05507, 0.0138794, 0.0168086] # 695nm
|
||||
- [0.999137, 0.0126962, 0.0155962] # 700nm
|
||||
- [0.935888, 0.0114308, 0.0147806] # 705nm
|
||||
- [0.869866, 0.0101682, 0.0134294] # 710nm
|
||||
- [0.799826, 0.0091101, 0.0124033] # 715nm
|
||||
- [0.73307, 0.00829069, 0.0115992] # 720nm
|
||||
- [0.666632, 0.00808895, 0.0108166] # 725nm
|
||||
- [0.600848, 0.00813718, 0.00973079] # 730nm
|
||||
- [0.540287, 0.00730659, 0.00973079] # 735nm
|
||||
- [0.478772, 0.00644088, 0.00948581] # 740nm
|
||||
- [0.420256, 0.00579725, 0.00803998] # 745nm
|
||||
- [0.367259, 0, 0.00690876] # 750nm
|
||||
- [0, 0, 0] # 755nm
|
||||
- [0, 0, 0] # 760nm
|
||||
- [0, 0, 0] # 765nm
|
||||
- [0, 0, 0] # 770nm
|
||||
- [0, 0, 0] # 775nm
|
||||
- [0, 0, 0] # 780nm
|
||||
|
||||
# The support's own density -- film base plus, for a colour negative, the
|
||||
# orange mask. Flat zero where the datasheet does not give it.
|
||||
base_density: [0.113637, 0.113635, 0.113629, 0.113617, 0.113591, 0.113541, 0.113451, 0.113297, 0.113045, 0.112657, 0.112088, 0.111294, 0.110236, 0.108888, 0.107242, 0.105307, 0.103109, 0.100686, 0.0980824, 0.0953448, 0.0925152, 0.0896305, 0.0867224, 0.0838195, 0.0809498, 0.0781429, 0.0754307, 0.072847, 0.0704243, 0.0681903, 0.0661639, 0.0643517, 0.0627473, 0.061332, 0.0600789, 0.0589566, 0.0579344, 0.0569854, 0.0560894, 0.0552338, 0.0544135, 0.05363, 0.05289, 0.0522035, 0.0515817, 0.0510349, 0.0505699, 0.0501894, 0.0498905, 0.0496661, 0.0495053, 0.0493957, 0.0493247, 0.0492811, 0.0492557, 0.0492417, 0.0492344, 0.0492308, 0.0492292, 0.0492285, 0.0492282, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281, 0.0492281]
|
||||
|
||||
# The characteristic curves: density against log10 exposure, sampled
|
||||
# uniformly over [-3, 4].
|
||||
log_exposure_min: -3
|
||||
log_exposure_max: 4
|
||||
density_curves:
|
||||
- [-0.00082207, -0.00082089, -0.0008278]
|
||||
- [-0.00070251, -0.00068042, -0.00074299]
|
||||
- [-0.00050646, -0.00043818, -0.00059421]
|
||||
- [-0.00029511, -0.00018684, -0.00040366]
|
||||
- [-0.00012342, -3.2448e-05, -0.00017405]
|
||||
- [-5.7059e-06, -1.3791e-05, 8.2719e-05]
|
||||
- [9.1999e-05, -5.3052e-05, 0.00030861]
|
||||
- [0.00021437, -1.8094e-05, 0.00041835]
|
||||
- [0.00036418, 0.00014486, 0.00037072]
|
||||
- [0.00048967, 0.00034766, 0.00021435]
|
||||
- [0.00052986, 0.00044217, 4.9948e-05]
|
||||
- [0.00046977, 0.0003613, -5.334e-05]
|
||||
- [0.00032883, 0.00015162, -7.5506e-05]
|
||||
- [0.00015441, -7.0724e-05, -3.2627e-05]
|
||||
- [-2.3977e-06, -0.00019632, 4.8881e-05]
|
||||
- [-0.00010783, -0.00018362, 0.00014965]
|
||||
- [-0.00014853, -6.686e-05, 0.00025336]
|
||||
- [-0.00012144, 8.2629e-05, 0.00033274]
|
||||
- [-2.6361e-05, 0.00020353, 0.00034796]
|
||||
- [0.00012767, 0.00026558, 0.00026816]
|
||||
- [0.00030747, 0.00026076, 0.00010248]
|
||||
- [0.00045058, 0.00018801, -8.7828e-05]
|
||||
- [0.00048483, 5.4486e-05, -0.00021447]
|
||||
- [0.00036793, -0.00010806, -0.0002141]
|
||||
- [0.00012262, -0.00023675, -9.1985e-05]
|
||||
- [-0.00016103, -0.00026207, 7.6502e-05]
|
||||
- [-0.00036566, -0.00015576, 0.00019237]
|
||||
- [-0.00040922, 3.4806e-05, 0.0001975]
|
||||
- [-0.00029184, 0.00020223, 0.0001122]
|
||||
- [-9.5598e-05, 0.0002456, 1.8958e-05]
|
||||
- [6.4346e-05, 0.00014515, 3.3839e-06]
|
||||
- [0.00010726, -1.3323e-05, 9.4186e-05]
|
||||
- [3.0731e-05, -9.0986e-05, 0.00024356]
|
||||
- [-9.5183e-05, 4.5391e-06, 0.00035985]
|
||||
- [-0.00017677, 0.0002432, 0.00036739]
|
||||
- [-0.00015513, 0.00047913, 0.00025196]
|
||||
- [-3.7732e-05, 0.00054189, 6.3884e-05]
|
||||
- [0.00011277, 0.00035217, -0.00011721]
|
||||
- [0.00022, -1.8482e-05, -0.00022712]
|
||||
- [0.00023668, -0.00038648, -0.00024211]
|
||||
- [0.00016449, -0.00056744, -0.00017542]
|
||||
- [4.5733e-05, -0.00048498, -5.8592e-05]
|
||||
- [-6.1163e-05, -0.00020711, 7.2217e-05]
|
||||
- [-0.00010543, 0.00010638, 0.00017793]
|
||||
- [-5.838e-05, 0.00030228, 0.0002157]
|
||||
- [7.9904e-05, 0.00031091, 0.00014993]
|
||||
- [0.00027736, 0.00016019, -2.3776e-05]
|
||||
- [0.00047152, -6.566e-05, -0.00025874]
|
||||
- [0.0005833, -0.00028087, -0.00046438]
|
||||
- [0.0005422, -0.00042305, -0.00055043]
|
||||
- [0.00032611, -0.00047594, -0.00049841]
|
||||
- [8.5758e-06, -0.00044112, -0.00034187]
|
||||
- [-0.00025635, -0.00030015, -0.00010732]
|
||||
- [-0.00033721, -6.1138e-05, 0.00016606]
|
||||
- [-0.00023362, 0.00017239, 0.00037915]
|
||||
- [-7.3449e-05, 0.0002207, 0.00041402]
|
||||
- [3.2502e-06, -3.4787e-05, 0.00025723]
|
||||
- [-4.7319e-05, -0.0005097, 5.7212e-05]
|
||||
- [-0.00017546, -0.00092832, 1.3464e-05]
|
||||
- [-0.00032121, -0.0010273, 0.00017569]
|
||||
- [-0.00041149, -0.00071637, 0.00041454]
|
||||
- [-0.00038262, -0.00012697, 0.00054762]
|
||||
- [-0.00020614, 0.00047118, 0.00047235]
|
||||
- [9.0769e-05, 0.00084241, 0.00022692]
|
||||
- [0.00041803, 0.00092424, -5.6038e-05]
|
||||
- [0.00064508, 0.00083595, -0.00025289]
|
||||
- [0.00065906, 0.00075679, -0.00032172]
|
||||
- [0.00043671, 0.00077331, -0.00029231]
|
||||
- [8.5246e-05, 0.00081752, -0.00019681]
|
||||
- [-0.00018734, 0.0007449, -1.4069e-05]
|
||||
- [-0.00017174, 0.00048813, 0.00031617]
|
||||
- [0.00022531, 0.00015841, 0.00082016]
|
||||
- [0.00092137, 4.2207e-06, 0.0014272]
|
||||
- [0.0017036, 0.00024924, 0.0019845]
|
||||
- [0.0023606, 0.00093112, 0.0023549]
|
||||
- [0.0028196, 0.0018688, 0.0025316]
|
||||
- [0.003202, 0.0027911, 0.0026808]
|
||||
- [0.003757, 0.0035367, 0.0030725]
|
||||
- [0.0047208, 0.0041749, 0.0039422]
|
||||
- [0.0061942, 0.0049591, 0.0053756]
|
||||
- [0.0081217, 0.0061484, 0.0072932]
|
||||
- [0.010381, 0.0078377, 0.0095378]
|
||||
- [0.012911, 0.0099305, 0.011995]
|
||||
- [0.015788, 0.012279, 0.014663]
|
||||
- [0.019192, 0.014885, 0.017636]
|
||||
- [0.023296, 0.017997, 0.021038]
|
||||
- [0.028165, 0.022016, 0.02497]
|
||||
- [0.033751, 0.027284, 0.029525]
|
||||
- [0.040025, 0.033843, 0.034821]
|
||||
- [0.047094, 0.041409, 0.040999]
|
||||
- [0.055231, 0.049631, 0.048231]
|
||||
- [0.064784, 0.058436, 0.05671]
|
||||
- [0.07604, 0.068171, 0.066578]
|
||||
- [0.089121, 0.079452, 0.07782]
|
||||
- [0.104, 0.092819, 0.09023]
|
||||
- [0.12061, 0.10847, 0.10355]
|
||||
- [0.13893, 0.12624, 0.11774]
|
||||
- [0.15911, 0.14581, 0.13305]
|
||||
- [0.18145, 0.167, 0.14986]
|
||||
- [0.20626, 0.18994, 0.16844]
|
||||
- [0.23376, 0.21502, 0.18893]
|
||||
- [0.26407, 0.24262, 0.21127]
|
||||
- [0.29734, 0.27285, 0.23546]
|
||||
- [0.33395, 0.30553, 0.26162]
|
||||
- [0.37453, 0.34041, 0.28998]
|
||||
- [0.41986, 0.37745, 0.32074]
|
||||
- [0.47052, 0.41697, 0.35396]
|
||||
- [0.52668, 0.45953, 0.38953]
|
||||
- [0.58806, 0.5056, 0.42742]
|
||||
- [0.6542, 0.5553, 0.46791]
|
||||
- [0.72487, 0.60839, 0.51166]
|
||||
- [0.80034, 0.66457, 0.55956]
|
||||
- [0.8813, 0.72367, 0.61233]
|
||||
- [0.96846, 0.7857, 0.67023]
|
||||
- [1.0621, 0.85079, 0.733]
|
||||
- [1.1621, 0.91912, 0.80009]
|
||||
- [1.2679, 0.99089, 0.87093]
|
||||
- [1.3785, 1.066, 0.94551]
|
||||
- [1.4926, 1.1438, 1.0246]
|
||||
- [1.6092, 1.2238, 1.1093]
|
||||
- [1.7276, 1.3062, 1.1995]
|
||||
- [1.8478, 1.3921, 1.2938]
|
||||
- [1.9691, 1.4818, 1.3902]
|
||||
- [2.0903, 1.5738, 1.4872]
|
||||
- [2.2092, 1.6659, 1.5844]
|
||||
- [2.3246, 1.7561, 1.6819]
|
||||
- [2.4356, 1.844, 1.7794]
|
||||
- [2.5427, 1.9305, 1.8755]
|
||||
- [2.6461, 2.0169, 1.9685]
|
||||
- [2.7456, 2.1036, 2.0569]
|
||||
- [2.8403, 2.1894, 2.1404]
|
||||
- [2.9292, 2.2726, 2.2192]
|
||||
- [3.0116, 2.3515, 2.2941]
|
||||
- [3.0875, 2.4256, 2.365]
|
||||
- [3.1576, 2.4956, 2.4314]
|
||||
- [3.2223, 2.5624, 2.4925]
|
||||
- [3.282, 2.6265, 2.548]
|
||||
- [3.3369, 2.6875, 2.5985]
|
||||
- [3.387, 2.7447, 2.6449]
|
||||
- [3.4326, 2.7979, 2.6877]
|
||||
- [3.474, 2.8469, 2.7269]
|
||||
- [3.5117, 2.8918, 2.762]
|
||||
- [3.546, 2.9329, 2.7927]
|
||||
- [3.5774, 2.9707, 2.8193]
|
||||
- [3.6058, 3.0056, 2.8427]
|
||||
- [3.6312, 3.0376, 2.8639]
|
||||
- [3.6538, 3.0669, 2.8836]
|
||||
- [3.6739, 3.0936, 2.902]
|
||||
- [3.6918, 3.1181, 2.9187]
|
||||
- [3.7078, 3.1404, 2.9336]
|
||||
- [3.7219, 3.1607, 2.9464]
|
||||
- [3.7343, 3.1789, 2.9574]
|
||||
- [3.7449, 3.1951, 2.9669]
|
||||
- [3.7541, 3.2093, 2.9754]
|
||||
- [3.762, 3.2217, 2.9829]
|
||||
- [3.7688, 3.2326, 2.9894]
|
||||
- [3.7745, 3.2423, 2.9949]
|
||||
- [3.7792, 3.2506, 2.9994]
|
||||
- [3.7829, 3.2578, 3.0031]
|
||||
- [3.7861, 3.2638, 3.0061]
|
||||
- [3.7889, 3.2688, 3.0089]
|
||||
- [3.7916, 3.2731, 3.0116]
|
||||
- [3.794, 3.277, 3.014]
|
||||
- [3.7961, 3.2804, 3.0162]
|
||||
- [3.7975, 3.2835, 3.0178]
|
||||
- [3.7982, 3.2859, 3.0191]
|
||||
- [3.7984, 3.2877, 3.02]
|
||||
- [3.7987, 3.2888, 3.0208]
|
||||
- [3.7993, 3.2895, 3.0215]
|
||||
- [3.8001, 3.29, 3.022]
|
||||
- [3.8008, 3.2905, 3.0224]
|
||||
- [3.801, 3.291, 3.0224]
|
||||
- [3.8009, 3.2916, 3.0223]
|
||||
- [3.8006, 3.2921, 3.0221]
|
||||
- [3.8007, 3.2926, 3.022]
|
||||
- [3.8011, 3.2929, 3.0222]
|
||||
- [3.8016, 3.293, 3.0225]
|
||||
- [3.8017, 3.2929, 3.0228]
|
||||
- [3.8014, 3.2926, 3.023]
|
||||
- [3.8007, 3.2924, 3.023]
|
||||
- [3.8002, 3.2924, 3.0228]
|
||||
- [3.8003, 3.2927, 3.0227]
|
||||
- [3.8009, 3.2931, 3.0226]
|
||||
- [3.8015, 3.2934, 3.0228]
|
||||
- [3.8017, 3.2935, 3.0231]
|
||||
- [3.8012, 3.2934, 3.0233]
|
||||
- [3.8002, 3.2931, 3.0233]
|
||||
- [3.7995, 3.293, 3.0231]
|
||||
- [3.7995, 3.2931, 3.0228]
|
||||
- [3.8003, 3.2933, 3.0226]
|
||||
- [3.8014, 3.2936, 3.0226]
|
||||
- [3.802, 3.2936, 3.0228]
|
||||
- [3.8019, 3.2933, 3.0231]
|
||||
- [3.8013, 3.293, 3.0233]
|
||||
- [3.8006, 3.2929, 3.0232]
|
||||
- [3.8005, 3.2931, 3.023]
|
||||
- [3.8009, 3.2936, 3.0228]
|
||||
- [3.8016, 3.294, 3.0226]
|
||||
- [3.802, 3.2942, 3.0227]
|
||||
- [3.8019, 3.2937, 3.0229]
|
||||
- [3.8014, 3.293, 3.0232]
|
||||
- [3.8009, 3.2922, 3.0233]
|
||||
- [3.8004, 3.2919, 3.0235]
|
||||
- [3.8002, 3.292, 3.0236]
|
||||
- [3.8003, 3.2925, 3.0236]
|
||||
- [3.8005, 3.293, 3.0235]
|
||||
- [3.8009, 3.2933, 3.0233]
|
||||
- [3.8013, 3.2936, 3.023]
|
||||
- [3.8015, 3.2937, 3.0229]
|
||||
- [3.8015, 3.2937, 3.023]
|
||||
- [3.8015, 3.2938, 3.0232]
|
||||
- [3.8015, 3.2939, 3.0232]
|
||||
- [3.8014, 3.294, 3.0231]
|
||||
- [3.8013, 3.294, 3.0228]
|
||||
- [3.8011, 3.2939, 3.0224]
|
||||
- [3.801, 3.2936, 3.0222]
|
||||
- [3.801, 3.2932, 3.0222]
|
||||
- [3.8011, 3.2928, 3.0224]
|
||||
- [3.8012, 3.2927, 3.0227]
|
||||
- [3.8014, 3.2927, 3.023]
|
||||
- [3.8015, 3.2929, 3.0232]
|
||||
- [3.8014, 3.293, 3.0233]
|
||||
- [3.8012, 3.293, 3.0233]
|
||||
- [3.801, 3.293, 3.0233]
|
||||
- [3.8008, 3.293, 3.0233]
|
||||
- [3.8008, 3.293, 3.0232]
|
||||
- [3.8009, 3.2931, 3.023]
|
||||
- [3.801, 3.2933, 3.0228]
|
||||
- [3.8012, 3.2935, 3.0228]
|
||||
- [3.8013, 3.2936, 3.023]
|
||||
- [3.8013, 3.2935, 3.0232]
|
||||
- [3.8012, 3.2935, 3.0233]
|
||||
- [3.801, 3.2934, 3.0232]
|
||||
- [3.8009, 3.2934, 3.0229]
|
||||
- [3.8009, 3.2934, 3.0226]
|
||||
- [3.801, 3.2933, 3.0226]
|
||||
- [3.8012, 3.2932, 3.0227]
|
||||
- [3.8014, 3.293, 3.023]
|
||||
- [3.8016, 3.2928, 3.0232]
|
||||
- [3.8016, 3.2928, 3.0231]
|
||||
- [3.8014, 3.2929, 3.0229]
|
||||
- [3.8011, 3.2931, 3.0227]
|
||||
- [3.8007, 3.2934, 3.0227]
|
||||
- [3.8005, 3.2935, 3.023]
|
||||
- [3.8006, 3.2935, 3.0235]
|
||||
- [3.8008, 3.2933, 3.0238]
|
||||
- [3.8011, 3.293, 3.0239]
|
||||
- [3.8014, 3.2928, 3.0237]
|
||||
- [3.8014, 3.2927, 3.0234]
|
||||
- [3.8013, 3.2928, 3.0232]
|
||||
- [3.8011, 3.2931, 3.0231]
|
||||
- [3.8009, 3.2933, 3.0231]
|
||||
- [3.8008, 3.2935, 3.0229]
|
||||
- [3.8008, 3.2936, 3.0228]
|
||||
- [3.8007, 3.2936, 3.0227]
|
||||
- [3.8007, 3.2937, 3.0226]
|
||||
@@ -1,452 +0,0 @@
|
||||
# Generated by tools/film-profiles/convert.py from spektrafilm.
|
||||
# Do not edit by hand: re-run the converter instead.
|
||||
#
|
||||
# spektrafilm by Andrea Volpato, https://github.com/andreavolpato/spektrafilm
|
||||
# Licensed CC BY-SA 4.0. Modified for DarkRoom: trimmed to the fields the
|
||||
# renderer uses and reformatted; see profiles/CHANGELOG.txt.
|
||||
|
||||
version: '0.3.2'
|
||||
stock: kodak_2393
|
||||
name: 'Kodak Vision Premier 2393'
|
||||
kind: negative # negative | positive
|
||||
support: film # film | paper
|
||||
stage: printing # filming | printing
|
||||
monochrome: false
|
||||
reference_illuminant: TH-KG3
|
||||
viewing_illuminant: K75P
|
||||
|
||||
# log10 spectral sensitivity per layer, 380-780nm at 5nm, in R,G,B layer
|
||||
# order. A null upstream means the datasheet has no reading there, which is
|
||||
# blindness, so it is written as the sentinel the loader reads as such.
|
||||
log_sensitivity:
|
||||
- [-6.38706, -6.73576, 0.220346] # 380nm
|
||||
- [-6.34032, -6.45024, 0.161528] # 385nm
|
||||
- [-6.29218, -6.15008, 0.187294] # 390nm
|
||||
- [-6.24258, -5.83412, 0.251027] # 395nm
|
||||
- [-6.19146, -5.50108, 0.314302] # 400nm
|
||||
- [-6.13874, -5.14953, 0.366303] # 405nm
|
||||
- [-6.08435, -4.7779, 0.421851] # 410nm
|
||||
- [-6.0282, -4.38441, 0.490894] # 415nm
|
||||
- [-5.97021, -3.96707, 0.582608] # 420nm
|
||||
- [-5.91029, -3.52365, 0.677497] # 425nm
|
||||
- [-5.84834, -3.05162, 0.768436] # 430nm
|
||||
- [-5.78425, -2.54812, 0.856478] # 435nm
|
||||
- [-5.71791, -2.0099, 0.943324] # 440nm
|
||||
- [-5.6492, -1.43323, 1.0304] # 445nm
|
||||
- [-5.578, -0.842602, 1.11896] # 450nm
|
||||
- [-5.50416, -0.584435, 1.21087] # 455nm
|
||||
- [-5.42753, -0.490407, 1.27082] # 460nm
|
||||
- [-5.34795, -0.408291, 1.30207] # 465nm
|
||||
- [-5.26525, -0.330238, 1.20335] # 470nm
|
||||
- [-5.17925, -0.254033, 0.90501] # 475nm
|
||||
- [-5.08973, -0.141301, 0.522446] # 480nm
|
||||
- [-4.99649, -0.00930504, 0.0924237] # 485nm
|
||||
- [-4.89927, 0.101568, -0.372282] # 490nm
|
||||
- [-4.79783, 0.165248, -0.864203] # 495nm
|
||||
- [-4.69189, 0.209648, -1.52674] # 500nm
|
||||
- [-4.58112, 0.236945, -2.15642] # 505nm
|
||||
- [-4.46521, 0.229435, -2.73766] # 510nm
|
||||
- [-4.34377, 0.222211, -3.27585] # 515nm
|
||||
- [-4.21641, 0.222618, -3.77559] # 520nm
|
||||
- [-4.08268, 0.242737, -4.24087] # 525nm
|
||||
- [-3.9421, 0.292345, -4.67513] # 530nm
|
||||
- [-3.79411, 0.410222, -5.08138] # 535nm
|
||||
- [-3.63813, 0.590163, -5.46223] # 540nm
|
||||
- [-3.47348, 0.800373, -5.82] # 545nm
|
||||
- [-3.29942, 0.853441, -6.15673] # 550nm
|
||||
- [-3.11513, 0.665462, -6.47421] # 555nm
|
||||
- [-2.91966, 0.359858, -6.77406] # 560nm
|
||||
- [-2.71198, 0.0564512, -7.0577] # 565nm
|
||||
- [-2.4909, -0.245276, -7.32641] # 570nm
|
||||
- [-2.25507, -0.572887, -7.58134] # 575nm
|
||||
- [-2.00299, -0.957532, -7.82352] # 580nm
|
||||
- [-1.74444, -1.42954, -8.05389] # 585nm
|
||||
- [-1.7149, -1.88137, -8.27329] # 590nm
|
||||
- [-1.68124, -2.30308, -8.48249] # 595nm
|
||||
- [-1.60974, -2.69758, -8.68217] # 600nm
|
||||
- [-1.54055, -3.06743, -8.87299] # 605nm
|
||||
- [-1.4476, -3.41486, -9.0555] # 610nm
|
||||
- [-1.37318, -3.74186, -9.23025] # 615nm
|
||||
- [-1.25774, -4.05016, -9.39772] # 620nm
|
||||
- [-1.16371, -4.34135, -9.55835] # 625nm
|
||||
- [-1.11482, -4.61679, -9.71256] # 630nm
|
||||
- [-1.08761, -4.87773, -9.86072] # 635nm
|
||||
- [-1.0646, -5.12529, -10.0032] # 640nm
|
||||
- [-1.03032, -5.36048, -10.1403] # 645nm
|
||||
- [-0.986823, -5.58419, -10.2723] # 650nm
|
||||
- [-0.935193, -5.79725, -10.3995] # 655nm
|
||||
- [-0.876013, -6.0004, -10.5221] # 660nm
|
||||
- [-0.799898, -6.19431, -10.6405] # 665nm
|
||||
- [-0.682598, -6.37961, -10.7548] # 670nm
|
||||
- [-0.577348, -6.55685, -10.8652] # 675nm
|
||||
- [-0.340454, -6.72655, -10.9719] # 680nm
|
||||
- [-0.172452, -6.88918, -11.0751] # 685nm
|
||||
- [-0.152901, -7.04517, -11.175] # 690nm
|
||||
- [-0.260099, -7.19492, -11.2718] # 695nm
|
||||
- [-0.449934, -7.3388, -11.3655] # 700nm
|
||||
- [-0.582681, -7.47714, -11.4563] # 705nm
|
||||
- [-0.758751, -7.61026, -11.5443] # 710nm
|
||||
- [-1.00918, -7.73846, -11.6298] # 715nm
|
||||
- [-1.40657, -7.86199, -11.7127] # 720nm
|
||||
- [-1.88394, -7.98111, -11.7932] # 725nm
|
||||
- [-2.34221, -8.09605, -11.8715] # 730nm
|
||||
- [-2.77092, -8.20702, -11.9475] # 735nm
|
||||
- [-3.17283, -8.31424, -12.0214] # 740nm
|
||||
- [-3.55038, -8.41788, -12.0933] # 745nm
|
||||
- [-3.90573, -8.51812, -12.1632] # 750nm
|
||||
- [-4.24077, -8.61513, -12.2313] # 755nm
|
||||
- [-4.5572, -8.70906, -12.2976] # 760nm
|
||||
- [-4.85652, -8.80005, -12.3621] # 765nm
|
||||
- [-5.14009, -8.88825, -12.425] # 770nm
|
||||
- [-5.40911, -8.97377, -12.4863] # 775nm
|
||||
- [-5.66469, -9.05674, -12.5461] # 780nm
|
||||
|
||||
# Spectral density of each layer's dye at unit density, same grid and order.
|
||||
dye_density:
|
||||
- [0.348359, 0.0541461, 0.346906] # 380nm
|
||||
- [0.352865, 0.0536263, 0.379638] # 385nm
|
||||
- [0.35245, 0.0550341, 0.439112] # 390nm
|
||||
- [0.348388, 0.058513, 0.51498] # 395nm
|
||||
- [0.339222, 0.0640309, 0.600159] # 400nm
|
||||
- [0.322342, 0.0707414, 0.69195] # 405nm
|
||||
- [0.297655, 0.0786195, 0.788426] # 410nm
|
||||
- [0.267174, 0.0890524, 0.879218] # 415nm
|
||||
- [0.234145, 0.0999203, 0.96289] # 420nm
|
||||
- [0.202712, 0.108705, 1.03275] # 425nm
|
||||
- [0.173819, 0.112497, 1.09239] # 430nm
|
||||
- [0.148217, 0.111658, 1.12838] # 435nm
|
||||
- [0.125212, 0.107855, 1.14948] # 440nm
|
||||
- [0.105622, 0.10559, 1.15803] # 445nm
|
||||
- [0.087123, 0.109437, 1.1566] # 450nm
|
||||
- [0.0697223, 0.121793, 1.14642] # 455nm
|
||||
- [0.0545372, 0.142381, 1.12446] # 460nm
|
||||
- [0.044302, 0.17005, 1.08653] # 465nm
|
||||
- [0.0386227, 0.205605, 1.02403] # 470nm
|
||||
- [0.0362424, 0.247192, 0.948582] # 475nm
|
||||
- [0.0357649, 0.303254, 0.858799] # 480nm
|
||||
- [0.0357649, 0.373669, 0.755465] # 485nm
|
||||
- [0.0357649, 0.453275, 0.647434] # 490nm
|
||||
- [0.0357649, 0.538276, 0.544068] # 495nm
|
||||
- [0.0357649, 0.630136, 0.447618] # 500nm
|
||||
- [0.0359242, 0.721171, 0.36334] # 505nm
|
||||
- [0.0386611, 0.808445, 0.290536] # 510nm
|
||||
- [0.0459982, 0.884974, 0.227495] # 515nm
|
||||
- [0.0579839, 0.951357, 0.183785] # 520nm
|
||||
- [0.0735487, 1.0057, 0.145589] # 525nm
|
||||
- [0.0906838, 1.05512, 0.116472] # 530nm
|
||||
- [0.108566, 1.09133, 0.0957736] # 535nm
|
||||
- [0.128974, 1.11508, 0.079254] # 540nm
|
||||
- [0.153795, 1.11885, 0.0644266] # 545nm
|
||||
- [0.182226, 1.10059, 0.0511924] # 550nm
|
||||
- [0.211981, 1.06033, 0.0407672] # 555nm
|
||||
- [0.24372, 1.00397, 0.0342171] # 560nm
|
||||
- [0.281891, 0.929598, 0.0313661] # 565nm
|
||||
- [0.329348, 0.835941, 0.0286413] # 570nm
|
||||
- [0.380305, 0.727228, 0.024393] # 575nm
|
||||
- [0.436474, 0.623032, 0.0193241] # 580nm
|
||||
- [0.497021, 0.525169, 0.0162746] # 585nm
|
||||
- [0.562689, 0.433257, 0.0149367] # 590nm
|
||||
- [0.631658, 0.354434, 0.0148282] # 595nm
|
||||
- [0.702837, 0.289379, 0.0148282] # 600nm
|
||||
- [0.77681, 0.236512, 0.0148282] # 605nm
|
||||
- [0.851016, 0.196467, 0.0148282] # 610nm
|
||||
- [0.919689, 0.159154, 0.0148282] # 615nm
|
||||
- [0.98382, 0.129968, 0.0148282] # 620nm
|
||||
- [1.04409, 0.109398, 0.0148282] # 625nm
|
||||
- [1.09715, 0.0945298, 0.0148282] # 630nm
|
||||
- [1.14579, 0.0817305, 0.0148282] # 635nm
|
||||
- [1.18129, 0.070091, 0.0148282] # 640nm
|
||||
- [1.2125, 0.0611412, 0.0148282] # 645nm
|
||||
- [1.23864, 0.0539845, 0.0148282] # 650nm
|
||||
- [1.25673, 0.0475015, 0.0148282] # 655nm
|
||||
- [1.26763, 0.04096, 0.0148282] # 660nm
|
||||
- [1.27133, 0.0346818, 0.0148282] # 665nm
|
||||
- [1.26877, 0.0296867, 0.0148282] # 670nm
|
||||
- [1.25731, 0.0268906, 0.0148282] # 675nm
|
||||
- [1.23729, 0.0248274, 0.0148282] # 680nm
|
||||
- [1.20862, 0.0214232, 0.0146066] # 685nm
|
||||
- [1.17028, 0.0177576, 0.0128235] # 690nm
|
||||
- [1.12542, 0.0151628, 0.0116854] # 695nm
|
||||
- [1.07635, 0.0138851, 0.013971] # 700nm
|
||||
- [1.01852, 0.0122393, 0.0150172] # 705nm
|
||||
- [0.954189, 0.0105577, 0.015368] # 710nm
|
||||
- [0.886115, 0.0073301, 0.0151927] # 715nm
|
||||
- [0.815741, 0.00369962, 0.0138001] # 720nm
|
||||
- [0.744385, 0.000580877, 0.00805805] # 725nm
|
||||
- [0.674477, 0.000358487, 0.0052296] # 730nm
|
||||
- [0.603866, 0.000358487, 0.00125818] # 735nm
|
||||
- [0.53315, 0.000358487, 0.00108627] # 740nm
|
||||
- [0.464063, 0.000358487, 0.00108627] # 745nm
|
||||
- [0, 0, 0] # 750nm
|
||||
- [0, 0, 0] # 755nm
|
||||
- [0, 0, 0] # 760nm
|
||||
- [0, 0, 0] # 765nm
|
||||
- [0, 0, 0] # 770nm
|
||||
- [0, 0, 0] # 775nm
|
||||
- [0, 0, 0] # 780nm
|
||||
|
||||
# The support's own density -- film base plus, for a colour negative, the
|
||||
# orange mask. Flat zero where the datasheet does not give it.
|
||||
base_density: [0.0745085, 0.0745078, 0.074506, 0.0745019, 0.0744936, 0.0744778, 0.0744489, 0.0743994, 0.0743188, 0.0741944, 0.074012, 0.0737573, 0.0734181, 0.0729862, 0.0724586, 0.0718383, 0.0711335, 0.0703568, 0.0695224, 0.0686451, 0.0677387, 0.0668154, 0.0658858, 0.0649601, 0.0640486, 0.0631625, 0.0623143, 0.0615171, 0.0607838, 0.0601254, 0.0595493, 0.0590581, 0.0586493, 0.0583155, 0.0580459, 0.057828, 0.0576492, 0.0574984, 0.0573669, 0.0572483, 0.0571388, 0.0570365, 0.0569411, 0.0568532, 0.0567737, 0.056704, 0.0566447, 0.0565962, 0.0565582, 0.0565296, 0.0565091, 0.0564951, 0.0564861, 0.0564805, 0.0564773, 0.0564755, 0.0564746, 0.0564741, 0.0564739, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738, 0.0564738]
|
||||
|
||||
# The characteristic curves: density against log10 exposure, sampled
|
||||
# uniformly over [-3, 4].
|
||||
log_exposure_min: -3
|
||||
log_exposure_max: 4
|
||||
density_curves:
|
||||
- [-0.00082207, -0.00082089, -0.0008278]
|
||||
- [-0.00070251, -0.00068042, -0.00074299]
|
||||
- [-0.00050646, -0.00043818, -0.00059421]
|
||||
- [-0.00029511, -0.00018684, -0.00040366]
|
||||
- [-0.00012342, -3.2448e-05, -0.00017405]
|
||||
- [-5.7059e-06, -1.3791e-05, 8.2719e-05]
|
||||
- [9.1999e-05, -5.3052e-05, 0.00030861]
|
||||
- [0.00021437, -1.8094e-05, 0.00041835]
|
||||
- [0.00036418, 0.00014486, 0.00037072]
|
||||
- [0.00048967, 0.00034766, 0.00021435]
|
||||
- [0.00052986, 0.00044217, 4.9948e-05]
|
||||
- [0.00046977, 0.0003613, -5.334e-05]
|
||||
- [0.00032883, 0.00015162, -7.5506e-05]
|
||||
- [0.00015441, -7.0724e-05, -3.2627e-05]
|
||||
- [-2.3977e-06, -0.00019632, 4.8881e-05]
|
||||
- [-0.00010783, -0.00018362, 0.00014965]
|
||||
- [-0.00014853, -6.686e-05, 0.00025336]
|
||||
- [-0.00012144, 8.2629e-05, 0.00033274]
|
||||
- [-2.6361e-05, 0.00020353, 0.00034796]
|
||||
- [0.00012767, 0.00026558, 0.00026816]
|
||||
- [0.00030747, 0.00026076, 0.00010248]
|
||||
- [0.00045058, 0.00018801, -8.7828e-05]
|
||||
- [0.00048483, 5.4486e-05, -0.00021447]
|
||||
- [0.00036793, -0.00010806, -0.0002141]
|
||||
- [0.00012262, -0.00023675, -9.1984e-05]
|
||||
- [-0.00016103, -0.00026207, 7.6503e-05]
|
||||
- [-0.00036566, -0.00015576, 0.00019237]
|
||||
- [-0.00040922, 3.4806e-05, 0.0001975]
|
||||
- [-0.00029184, 0.00020223, 0.0001122]
|
||||
- [-9.5598e-05, 0.0002456, 1.8959e-05]
|
||||
- [6.4346e-05, 0.00014515, 3.385e-06]
|
||||
- [0.00010726, -1.3323e-05, 9.4188e-05]
|
||||
- [3.0731e-05, -9.0986e-05, 0.00024357]
|
||||
- [-9.5183e-05, 4.5392e-06, 0.00035985]
|
||||
- [-0.00017677, 0.0002432, 0.0003674]
|
||||
- [-0.00015513, 0.00047913, 0.00025198]
|
||||
- [-3.7733e-05, 0.00054189, 6.39e-05]
|
||||
- [0.00011277, 0.00035218, -0.00011718]
|
||||
- [0.00022, -1.8481e-05, -0.00022708]
|
||||
- [0.00023667, -0.00038647, -0.00024206]
|
||||
- [0.00016449, -0.00056743, -0.00017533]
|
||||
- [4.5726e-05, -0.00048498, -5.8466e-05]
|
||||
- [-6.1174e-05, -0.00020711, 7.2404e-05]
|
||||
- [-0.00010544, 0.0001064, 0.0001782]
|
||||
- [-5.8407e-05, 0.0003023, 0.00021611]
|
||||
- [7.9861e-05, 0.00031094, 0.00015051]
|
||||
- [0.00027729, 0.00016024, -2.2941e-05]
|
||||
- [0.00047142, -6.5589e-05, -0.00025755]
|
||||
- [0.00058314, -0.00028076, -0.00046269]
|
||||
- [0.00054195, -0.00042289, -0.00054804]
|
||||
- [0.00032575, -0.0004757, -0.00049504]
|
||||
- [8.0356e-06, -0.00044079, -0.00033713]
|
||||
- [-0.00025714, -0.00029966, -0.0001007]
|
||||
- [-0.00033838, -6.0428e-05, 0.00017525]
|
||||
- [-0.00023534, 0.0001734, 0.00039182]
|
||||
- [-7.5975e-05, 0.00022215, 0.00043135]
|
||||
- [-4.119e-07, -3.2754e-05, 0.00028074]
|
||||
- [-5.2574e-05, -0.00050686, 8.8912e-05]
|
||||
- [-0.00018294, -0.00092438, 5.6025e-05]
|
||||
- [-0.00033179, -0.0010218, 0.00023261]
|
||||
- [-0.00042635, -0.00070888, 0.00049025]
|
||||
- [-0.00040343, -0.00011674, 0.00064761]
|
||||
- [-0.00023517, 0.00048505, 0.0006034]
|
||||
- [5.0501e-05, 0.00086105, 0.00039745]
|
||||
- [0.00036253, 0.00094912, 0.00016454]
|
||||
- [0.00056926, 0.00086891, 3.0952e-05]
|
||||
- [0.00055652, 0.00080017, 4.1715e-05]
|
||||
- [0.00029943, 0.00083003, 0.00017055]
|
||||
- [-9.7042e-05, 0.00089121, 0.00038935]
|
||||
- [-0.0004281, 0.00084002, 0.00072411]
|
||||
- [-0.0004888, 0.00061019, 0.0012408]
|
||||
- [-0.00019118, 0.00031416, 0.001972]
|
||||
- [0.00037652, 0.00020172, 0.0028538]
|
||||
- [0.00099577, 0.000498, 0.0037399]
|
||||
- [0.0014493, 0.0012422, 0.0045001]
|
||||
- [0.0016577, 0.0022552, 0.0051358]
|
||||
- [0.0017329, 0.0032687, 0.0058232]
|
||||
- [0.001911, 0.0041244, 0.0068447]
|
||||
- [0.0024117, 0.0048953, 0.0084491]
|
||||
- [0.0033186, 0.0058383, 0.010734]
|
||||
- [0.0045602, 0.007216, 0.013632]
|
||||
- [0.0059997, 0.0091274, 0.016994]
|
||||
- [0.0075624, 0.011482, 0.020715]
|
||||
- [0.0093067, 0.014141, 0.024803]
|
||||
- [0.01139, 0.017114, 0.029365]
|
||||
- [0.01396, 0.020658, 0.034535]
|
||||
- [0.017064, 0.025181, 0.040427]
|
||||
- [0.020642, 0.031032, 0.047144]
|
||||
- [0.024658, 0.038264, 0.054815]
|
||||
- [0.029209, 0.046614, 0.063592]
|
||||
- [0.034544, 0.055761, 0.073659]
|
||||
- [0.040984, 0.065656, 0.085223]
|
||||
- [0.048788, 0.076667, 0.098437]
|
||||
- [0.058068, 0.089421, 0.1133]
|
||||
- [0.068805, 0.10447, 0.12962]
|
||||
- [0.080939, 0.12204, 0.14717]
|
||||
- [0.094479, 0.14199, 0.16592]
|
||||
- [0.10957, 0.16404, 0.18616]
|
||||
- [0.12649, 0.18804, 0.20829]
|
||||
- [0.14551, 0.21413, 0.23263]
|
||||
- [0.16681, 0.24272, 0.25932]
|
||||
- [0.1905, 0.27416, 0.28838]
|
||||
- [0.2167, 0.30855, 0.3198]
|
||||
- [0.24568, 0.3457, 0.35373]
|
||||
- [0.27798, 0.38533, 0.39042]
|
||||
- [0.31419, 0.42736, 0.43007]
|
||||
- [0.35475, 0.47205, 0.47272]
|
||||
- [0.39977, 0.51983, 0.51824]
|
||||
- [0.449, 0.57106, 0.56656]
|
||||
- [0.50208, 0.62574, 0.6179]
|
||||
- [0.55887, 0.68357, 0.67286]
|
||||
- [0.61968, 0.74416, 0.73221]
|
||||
- [0.68523, 0.80728, 0.79659]
|
||||
- [0.75631, 0.87293, 0.86615]
|
||||
- [0.83343, 0.94119, 0.94064]
|
||||
- [0.91678, 1.0123, 1.0195]
|
||||
- [1.0063, 1.0864, 1.1024]
|
||||
- [1.1017, 1.1635, 1.1893]
|
||||
- [1.2023, 1.2434, 1.2811]
|
||||
- [1.3078, 1.3256, 1.379]
|
||||
- [1.4181, 1.4108, 1.4833]
|
||||
- [1.5336, 1.5, 1.5929]
|
||||
- [1.6544, 1.594, 1.7063]
|
||||
- [1.7796, 1.6918, 1.8223]
|
||||
- [1.9077, 1.7918, 1.941]
|
||||
- [2.0373, 1.8923, 2.0626]
|
||||
- [2.1679, 1.9932, 2.187]
|
||||
- [2.2997, 2.0956, 2.3132]
|
||||
- [2.4328, 2.2008, 2.4393]
|
||||
- [2.5669, 2.3096, 2.5638]
|
||||
- [2.7005, 2.421, 2.6859]
|
||||
- [2.8318, 2.5331, 2.8057]
|
||||
- [2.9595, 2.6439, 2.9235]
|
||||
- [3.0827, 2.7527, 3.0389]
|
||||
- [3.2014, 2.8598, 3.1506]
|
||||
- [3.3155, 2.9659, 3.2572]
|
||||
- [3.4248, 3.0712, 3.3576]
|
||||
- [3.5287, 3.1748, 3.4518]
|
||||
- [3.6268, 3.2756, 3.5403]
|
||||
- [3.7189, 3.3725, 3.6232]
|
||||
- [3.8049, 3.465, 3.7001]
|
||||
- [3.8851, 3.5525, 3.7698]
|
||||
- [3.9597, 3.6352, 3.8317]
|
||||
- [4.0292, 3.7133, 3.8862]
|
||||
- [4.0932, 3.7868, 3.9342]
|
||||
- [4.1518, 3.8558, 3.977]
|
||||
- [4.205, 3.9201, 4.0155]
|
||||
- [4.2534, 3.9798, 4.05]
|
||||
- [4.2973, 4.0351, 4.0803]
|
||||
- [4.3372, 4.0861, 4.1063]
|
||||
- [4.3732, 4.133, 4.1282]
|
||||
- [4.4055, 4.1757, 4.1464]
|
||||
- [4.4343, 4.2141, 4.1616]
|
||||
- [4.4598, 4.2485, 4.1745]
|
||||
- [4.4825, 4.2792, 4.1853]
|
||||
- [4.5025, 4.3066, 4.1942]
|
||||
- [4.5202, 4.331, 4.2014]
|
||||
- [4.5355, 4.3525, 4.2071]
|
||||
- [4.5487, 4.3714, 4.2114]
|
||||
- [4.5602, 4.3877, 4.2149]
|
||||
- [4.5704, 4.4018, 4.2179]
|
||||
- [4.5796, 4.4141, 4.2206]
|
||||
- [4.5879, 4.4249, 4.2229]
|
||||
- [4.5949, 4.4345, 4.2249]
|
||||
- [4.6006, 4.4428, 4.2264]
|
||||
- [4.6049, 4.4498, 4.2274]
|
||||
- [4.6082, 4.4553, 4.2281]
|
||||
- [4.6111, 4.4597, 4.2287]
|
||||
- [4.614, 4.4631, 4.2292]
|
||||
- [4.6167, 4.4659, 4.2296]
|
||||
- [4.619, 4.4683, 4.2298]
|
||||
- [4.6205, 4.4705, 4.2297]
|
||||
- [4.6214, 4.4724, 4.2294]
|
||||
- [4.6221, 4.474, 4.2292]
|
||||
- [4.6228, 4.4754, 4.2291]
|
||||
- [4.6238, 4.4764, 4.2292]
|
||||
- [4.6248, 4.4771, 4.2294]
|
||||
- [4.6253, 4.4774, 4.2297]
|
||||
- [4.6253, 4.4776, 4.2298]
|
||||
- [4.6248, 4.4777, 4.2298]
|
||||
- [4.6245, 4.4779, 4.2296]
|
||||
- [4.6247, 4.4784, 4.2295]
|
||||
- [4.6254, 4.4789, 4.2295]
|
||||
- [4.6262, 4.4793, 4.2296]
|
||||
- [4.6264, 4.4795, 4.2299]
|
||||
- [4.6259, 4.4795, 4.2301]
|
||||
- [4.625, 4.4793, 4.2301]
|
||||
- [4.6243, 4.4791, 4.2299]
|
||||
- [4.6244, 4.4793, 4.2296]
|
||||
- [4.6252, 4.4795, 4.2294]
|
||||
- [4.6262, 4.4798, 4.2294]
|
||||
- [4.6269, 4.4798, 4.2296]
|
||||
- [4.6268, 4.4796, 4.2299]
|
||||
- [4.6262, 4.4793, 4.2301]
|
||||
- [4.6255, 4.4791, 4.23]
|
||||
- [4.6254, 4.4794, 4.2298]
|
||||
- [4.6258, 4.4799, 4.2295]
|
||||
- [4.6265, 4.4803, 4.2294]
|
||||
- [4.6269, 4.4804, 4.2295]
|
||||
- [4.6268, 4.48, 4.2297]
|
||||
- [4.6264, 4.4793, 4.23]
|
||||
- [4.6258, 4.4785, 4.2301]
|
||||
- [4.6253, 4.4782, 4.2302]
|
||||
- [4.6251, 4.4783, 4.2303]
|
||||
- [4.6252, 4.4787, 4.2304]
|
||||
- [4.6254, 4.4792, 4.2303]
|
||||
- [4.6258, 4.4796, 4.23]
|
||||
- [4.6262, 4.4799, 4.2298]
|
||||
- [4.6264, 4.48, 4.2297]
|
||||
- [4.6264, 4.48, 4.2298]
|
||||
- [4.6264, 4.4801, 4.2299]
|
||||
- [4.6264, 4.4802, 4.23]
|
||||
- [4.6263, 4.4803, 4.2299]
|
||||
- [4.6262, 4.4803, 4.2296]
|
||||
- [4.626, 4.4802, 4.2292]
|
||||
- [4.6259, 4.4798, 4.229]
|
||||
- [4.6259, 4.4794, 4.2289]
|
||||
- [4.626, 4.4791, 4.2292]
|
||||
- [4.6261, 4.479, 4.2295]
|
||||
- [4.6263, 4.479, 4.2298]
|
||||
- [4.6264, 4.4792, 4.23]
|
||||
- [4.6263, 4.4793, 4.2301]
|
||||
- [4.6261, 4.4793, 4.2301]
|
||||
- [4.6259, 4.4793, 4.2301]
|
||||
- [4.6257, 4.4792, 4.2301]
|
||||
- [4.6257, 4.4793, 4.2299]
|
||||
- [4.6258, 4.4794, 4.2298]
|
||||
- [4.626, 4.4796, 4.2296]
|
||||
- [4.6261, 4.4798, 4.2296]
|
||||
- [4.6262, 4.4799, 4.2298]
|
||||
- [4.6262, 4.4798, 4.23]
|
||||
- [4.6261, 4.4798, 4.2301]
|
||||
- [4.6259, 4.4797, 4.23]
|
||||
- [4.6258, 4.4797, 4.2297]
|
||||
- [4.6258, 4.4797, 4.2294]
|
||||
- [4.6259, 4.4796, 4.2293]
|
||||
- [4.6261, 4.4795, 4.2295]
|
||||
- [4.6263, 4.4793, 4.2298]
|
||||
- [4.6265, 4.4791, 4.23]
|
||||
- [4.6265, 4.4791, 4.2299]
|
||||
- [4.6263, 4.4792, 4.2297]
|
||||
- [4.626, 4.4794, 4.2295]
|
||||
- [4.6256, 4.4797, 4.2295]
|
||||
- [4.6254, 4.4798, 4.2298]
|
||||
- [4.6255, 4.4798, 4.2302]
|
||||
- [4.6257, 4.4796, 4.2306]
|
||||
- [4.626, 4.4793, 4.2307]
|
||||
- [4.6263, 4.479, 4.2305]
|
||||
- [4.6263, 4.479, 4.2301]
|
||||
- [4.6262, 4.4791, 4.2299]
|
||||
- [4.626, 4.4793, 4.2299]
|
||||
- [4.6258, 4.4796, 4.2298]
|
||||
- [4.6258, 4.4797, 4.2297]
|
||||
- [4.6257, 4.4799, 4.2296]
|
||||
- [4.6256, 4.4799, 4.2295]
|
||||
- [4.6256, 4.4799, 4.2294]
|
||||