Compare commits

..
129 Commits
Author SHA1 Message Date
dtourolle 5ae742816d Release 0.19.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m29s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m8s
Build and test / Android (aarch64) (push) Successful in 30m34s
Build and test / android-image (push) Successful in 3s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / Desktop (Linux) (push) Successful in 48m59s
Build and test / windows-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 48s
Build and test / Windows (x86_64, cross) (push) Successful in 35m51s
Build and test / Publish the release (push) Successful in 51s
2026-09-28 19:58:04 -04:00
dtourolle 8d66fa9be5 Show the composite in the grid in the manual, and say how it gets there
The panorama scene now ends in the grid, on the composite in its wide
cell beside the twelve frames it was made from, with the thumbnail the
merge made: the picture is taken straight after the merge, before any
rescan could have found the file. The manual's library section says how
a panorama's cell is sized and packed, and its panorama section that the
composite is in the grid the moment it is written. panorama.md §9 records
the race that kept it out, the catalogue-then-register path that
replaced it, how the thumbnail is developed, and the layout's classes.
2026-09-28 19:57:37 -04:00
dtourolle 34630ff752 Name the folder lookup the merge page is handed, for clippy 2026-09-28 19:57:37 -04:00
dtourolle 3b7d7129ff Learn a photograph's shape from its header
A panorama the merge did not make, a stitch from another program or a
phone's sweep, never had a width or height in the catalog, so it could
never be given a wide cell. The header read that dates a photograph now
records its size as it is seen, orientation applied, alongside the date.
2026-09-28 19:57:37 -04:00
dtourolle 6050a8e703 Size a panorama's cell and thumbnail by class: two, three or four columns
One lookup, natural_span, maps a photograph's aspect to the columns its
cell spans, and the same number names its thumbnail class, Wide2, Wide3
or Wide4, 512 pixels of long edge per column, so a 4:1 panorama is as
sharp across four columns as a frame is in one. The boundaries are
where the two neighbouring cells would leave the same share of
themselves undrawn, sqrt(s(s+1)): 2.45 and 3.46, with the first at 1.9
so a 3:2 frame stays a frame. A grid too narrow for the class falls
back to the widest that fits, the tablet gives the whole row, and a
cell asks for the class it is actually drawn at: its span, but never
more than its own class. The merge renders each wide class up to the
composite's own, which covers every fallback.
2026-09-28 19:57:37 -04:00
dtourolle ae4e1a0f07 Give a panorama a wide cell in the grid
A 4:1 composite drawn in one square cell is a strip a few pixels high.
A photograph about twice as wide as it is tall (1.9 and up) now spans
two columns, three from 2.9, with a thumbnail class of its own whose
long edge is sized for that width; on the tablet, or where the columns
are too few to put it beside anything, it takes the whole row.

Rows are computed in one place, library_ui::layout. The grid is a
lattice of slots: each cell is drawn at the slot Rust gives it, and a
wide cell that would not fit in what is left of a row starts the next
one, leaving the gap empty so the grid still reads in capture order.
The scrollbar spans the slots, a scroll reports a slot that the layout
turns back into an ordinal, and scrubs, restores and the cursor go
through the same conversion. Up and down step by rows through the
layout rather than by a row's worth of ordinals; left and right, a
shift-click's run, burst folding and the timeline are ordinal-based
and unchanged.

The window's own read carries each photograph's w and h, so the cells
know their shape with no query per cell. Where the wide ones sit in
the whole list is one query, run when what the grid lists changes or
when a window finds the layout out of date, and a library with no
panorama answers it from a partial index created on first use
(images_wide), not a schema bump. The merge makes the wide thumbnail
for a wide composite along with the others.
2026-09-28 19:57:37 -04:00
dtourolle ce5b7d72e3 Thumbnail a composite during the merge, as develop first shows it
The DNG a merge writes has no embedded preview, and an embedded preview
is all the grid's thumbnail path reads, so a composite stood in the grid
as a blank cell until it was opened. Reading an 800 MB file back to make
one would cost what the merge already has in hand.

The bands are box-reduced as they are written, after the border fill,
into a copy 4096 pixels long. That copy is written as a linear DNG in
memory with the composite's own profile, header and crop, and opened
through open_session, the function develop opens every file with: the
same decode, the default graph and view transform, the as-shot white
balance and the conversion to the display's space. The grid and large
thumbnails are rendered from that session, staged beside the payload
before the rename releases it to a drain, and put in the store under the
file id once the upload has learned it. Until then the grid draws them
from memory, so the cell is not blank while the file is uploading.

A test develops a synthetic composite both ways, the whole file as
develop opens it and the merge's reduced copy, and holds the thumbnail's
mean, 95th and 99.5th luma percentiles to within 3-4 levels of develop's
render; the naive balanced-and-gamma picture the merge's preview draws
misses by 13.
2026-09-28 19:57:37 -04:00
dtourolle 98a67393d9 Catalogue a merged panorama the moment it is written
A finished merge drained the outbox and started a rescan beside it. The
scan raced the upload: on a folder library the 800 MB copy was still
running when the folder was listed, on Nextcloud the upload takes
minutes, and either way the listing lacked the composite, recorded the
folder's validator, and nothing looked again until the next sync pass.

The job now reports what the catalog needs (the name, the size of the
picture it opens on, the capture time it wrote into the DNG) and the
library writes the row at once, in one transaction, keyed where the scan
will list the file; a composite with no time of its own takes its
sources' earliest. The grid reloads and shows it beside its sources.

The upload then gives the row what only the server knows: after sending
a file the catalog already has a row for, it lists the folder once, takes
the file id the server assigned, and records it (and, once the merge
makes them, the thumbnails waiting beside the payload) under that id.
Every drain now rescans when something landed in the library, after the
upload rather than beside it. A second merge of the same frames is no
longer named over the first: the name is checked against the catalog's
names in that folder, which is all that knows it once the outbox is empty.
2026-09-28 19:57:37 -04:00
dtourolle 37136f7377 Let one outbox drain run at a time
A finished merge drained the outbox and the sync pass that followed
drained it again beside it: each read the 800 MB composite into memory
and sent it, and the later one found its record cleared underneath it
and logged the file as missing. Drains now take a lock and the one that
waited finds the queue empty.
2026-09-28 19:57:37 -04:00
dtourolle e2e2181469 Write the composite under a .part name until it is whole
The merge writes its outbox record before the DNG, which takes minutes,
and a drain that ran in the meantime took whatever lay at the record's
name: a sync pass that fired mid-merge uploaded the first part of the
composite and cleared the record. The file is now written as x.dng.part
and renamed into place once the last strip is in; the drain skips a
record whose payload does not exist yet.
2026-09-28 19:57:37 -04:00
dtourolle ad27369cdc Draft the design for learned denoise (FR-DEV-3g)
A design draft for the learned stage outstanding.md §3 lists as missing:
a joint demosaic-and-denoise network that runs on the mosaic, in the
slot architecture.md §5.2 reserves for it, with its result kept as a
cache rather than a new file in the library. It covers the model and
tiling, synthetic training pairs from the library and a per-body noise
calibration, evaluation on real pairs, the Amount control, speed on the
tablet, X-Trans, and the decisions still open. Nothing in it is built;
figures marked "estimate" wait for the measurements that replace them.
2026-09-28 07:31:35 -04:00
dtourolle 883b4aca10 Release 0.19.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m27s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m2s
Build and test / Android (aarch64) (push) Successful in 29m53s
Build and test / android-image (push) Successful in 2s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 50m11s
Build and test / windows-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 38s
Build and test / Windows (x86_64, cross) (push) Successful in 35m28s
Build and test / Publish the release (push) Successful in 1m10s
2026-09-27 19:50:46 -04:00
dtourolle 8102234e68 Re-record the film preset on the preset folders
presets-film.gif came from the tree before preset-tree landed, so it
opened a flat list; the scene now walks Film › Black and white under
the folders, on the scene-referred pipeline. presets.png and
presets-menu.png re-recorded identical: preset-tree recorded them on
D19 already.
2026-09-27 19:50:28 -04:00
dtourolle 757133d2a8 Picture a panorama too large for one texture being navigated
A 22 927 × 8966 Lightroom panorama of a glacier, opened in develop:
the wheel in from fit to the peaks, a slow pan along the ridge at that
zoom, back out, then 1:1 with a double-click and fit with another. The
scene borrows the file from outside the demo library, copies it into a
panorama folder, restarts so the scan finds it, and deletes it and the
sidecar the app writes beside it before restarting again; it is
registered last so no other scene sees it.

The film is fine ice and rock in every frame and was 22 MB at the
usual 960 px and ten frames a second, so a scene can now name its own
GIF size (GIF_SIZE, read by record.sh through `scenes.py gif`); this
one is 640 px at eight, 8.7 MB.
2026-09-27 19:43:09 -04:00
dtourolle 5e863fa718 Re-record the manual under the scene-referred pipeline
D19 changed how every raw photograph renders, so every picture was
stale; all forty are recorded again from this tree. Beside the ones
already in the page there is now one for Tone Mapping: an alpine frame
whose clouds sit near white, its White Point raised to bring them back
and lowered for a brighter picture, its Contrast raised, then Before.

The panorama film unticks the first frame and ticks it back, so the
Frames list and the re-solve show; it no longer films minutes of
"Reading the frames", cutting from the first seconds of the job to the
alignment. The manual's presets menu opens beside the rail, not beside
the photograph.
2026-09-27 19:43:09 -04:00
dtourolle e600dae3df Regenerate the traceability matrix after the doc-comment sweep
The D19 sweep reflowed comments in detail.rs, adjust.rs and demosaic.rs,
which moves the line numbers the matrix records.
2026-09-27 19:42:51 -04:00
dtourolle 330ece0abe Bring the README's features and standing up to 0.19.0
The develop paragraph counted eighteen operations fused into one
dispatch, before the view transform made nineteen and a detail stage
made the fusion two passes, and said nothing of editing on the scene
with Tone Mapping last. Presets are now a menu on the tool rail, a
panorama frame can be left out, and a linear DNG larger than one
texture develops. The requirement count and coverage are
traceability.md's (193, 85.5%), and tiled rendering is no longer
wholly unbuilt. outstanding.md gains its sweep note for 0.19.0 and
says in §11 what the frame choice changed.
2026-09-27 19:42:42 -04:00
dtourolle 4bd8d86c00 Record where a linear DNG too large for one texture goes
ARCH §5.3 still described only a tile cache nobody built; it now says
what 0.19.0 tiles and what it does not: a linear DNG past PROXY_EDGE
opens on a reduced copy, a finer render samples a full-resolution
window, and the export is cut into halo-grown tiles. display-and-
extension.md's FR-DSP-2 row said absent, outstanding.md said the halo
had nothing to read it and that such a file fell to the embedded
preview.

rawler now builds from third_party, which ARCH's stack table and §3.2,
the root Cargo.toml's comment ("two upstream crates ... for Android")
and third_party/README.md's bump procedure did not know; the README
also names each vendored crate's licence. panorama.md and the manual
say a composite this wide develops and exports.
2026-09-27 19:42:42 -04:00
dtourolle 6c749b469d Describe leaving a panorama frame out, and its white clouds
panorama.md still had a frame that did not fit end the job, and FR-MRG-5
still said the merge stops; since 621a6b83 the frame is named on its
row and unticked, and the rest are solved again from the pairs already
matched. §15 records the split of align into match_pairs and solve and
why re-aligning a subset from scratch moved every RANSAC seed, and the
rule e98b1def gave a blown sample, which §8's "nothing about being a
RAW is lost" now mentions. The manual says where the boxes are and
what an unplaceable frame does to Merge.
2026-09-27 19:42:42 -04:00
dtourolle affdaecaee Stop describing a base curve the pipeline no longer has
D19 retired the per-body base curve, moved the matrix ahead of the
edits and the film into the view transform's place, but a dozen doc
comments still listed the curve among what a pixel passes through, or
said the film skipped it. The detail stage's module doc still drew the
matrix after the edits and the last detail pass encoding, which the
view pass took over. The film crate's README gave the base curves as
its reason for being data, and the ops README's list of hand-written
nodes had neither the view transform nor three of the five kernels.

FR-MRG-2 gave the base curve as why the merge cuts below the profile;
the view transform is why now. The decision table still said colour
defaults were a per-body curve, and FR-DEV-3j said only the default
view transform skips a JPEG, where the node skips one whatever its
sliders say. frame-budget.md records the view pass as unmeasured.
2026-09-27 19:42:41 -04:00
dtourolle 31bcc3a462 File presets in folders that open and close, as collections do
The presets menu and sheet listed every preset under flat section
headings, seventy rows to scroll past. They now list folders, closed
until opened, with how many presets each holds; opening one shows what
is inside it, folders and presets indented beneath.

A category is spelled in the name: "Portraits/Warm skin" is Warm skin
in a Portraits folder under Yours. The file format does not change, so
an older build lists the whole path as the name; renaming a preset is
how it moves, and saving or renaming into a folder opens the way to it.
A Lightroom import names what it reads after the folders below the one
chosen, and a "/" in a displayed name becomes "∕" so it files nothing.
The shipped film sections become Film › Colour, Cinema and Black and
white.

The tree is built and flattened in Rust (PresetTree), each row carrying
its depth, and which folders are open is remembered for the session.

A PopupWindow keeps the size it was shown at, so a folder opened in the
menu pushed its contents under "Save or manage…"; the menu is shown
again after each toggle to take its new height. That is a function on
the rail because Slint 1.17 generates Rust that does not compile for a
popup's close() reached from inside the popup. The sheet's list takes a
preferred height of up to 400px, since a Flickable reports next to
nothing and an opened folder showed three rows.

The manual describes the folders and naming. Its pictures show the menu
with Film › Colour open, and a black-and-white stock applied from Film
› Black and white; the scenes aim popup rows from the rail's entry, and
pick the menu's "Film" over the develop column's film chooser.
2026-09-27 18:37:56 -04:00
dtourolle 89b464db0c Accept a tile halo that the frame's edge cuts short
The test demanded a full halo on every side except the frame's own
edge, but a tile one step in from it is grown to the edge and no
further, exactly where the untiled render stops too.
2026-09-27 17:37:06 -04:00
dtourolle 87b2599740 Record that exports of oversized linear DNGs now tile
FR-DSP-2 and NFR-RES-2 said tiling was unbuilt; the export half of it
now exists for a linear DNG past one texture. The interactive path and
spike S6 are unchanged.
2026-09-27 17:37:06 -04:00
dtourolle 885864b6a0 Open and export a linear DNG too large for one texture
A 22927×8966 Lightroom panorama opened as its embedded preview with
develop withheld, because no texture could hold it. DevelopSession now
opens a linear DNG past PROXY_EDGE (8192) on a box-reduced copy, and
keeps the full resolution on the CPU. The canvas at fit, the thumbnail,
the histograms and the masks work from the copy; a render finer than
it — the canvas zoomed in, a tile of the export — samples a window cut
from the full resolution, kept while the view stays inside it. The
export renders in halo-grown tiles of 4096 and assembles them.

On the panorama: decode 1.6 s, open 210 ms, canvas 37 ms, a zoomed
window 130 ms, the full-size export 5.9 s.
2026-09-27 17:37:06 -04:00
dtourolle 0007fa459f Develop a linear DNG from windows and reduced copies of it
DemosaicedImage::linear_rgb16_window uploads part of a linear DNG, or a
box-reduced copy of it, and says where it sits in the frame; size() now
reports the frame and texture_size() the texels, and the fused pass
writes the window into the shader's uniforms. EditGraph::source_region
finds the part of the source a view reads, and tiles::plan cuts a render
too large for one texture into halo-grown, grid-aligned tiles.

The GPU test renders frames a tile at a time from their own windows and
compares them with the whole: identical for point operations, within one
code value when straightened with clarity on.
2026-09-27 17:35:16 -04:00
dtourolle 1eab723c90 Say how far a detail chain reaches, for a tile's halo
radius() is the widest single pass. The chain's passes run in sequence,
so what a tile has to be grown by is their sum.
2026-09-27 17:34:16 -04:00
dtourolle e601bd806c Keep clarity's radius when the develop view zooms in
A frame fraction was taken of the render's short edge, and render_scale
folds the zoom into the region the render stands for. Zoomed to 1:1 on a
corner, clarity, texture and dehaze drew a quarter of the halo the export
gets, though the docs promise they preview honestly at any zoom.
RenderScale now carries the whole framed frame (within), and a frame
fraction is measured against it; at fit nothing changes.
2026-09-27 17:34:16 -04:00
dtourolle cfc1fea25a Let the fused shader sample a window of a larger source
A photograph larger than one texture has to be developed from a part of
it or from a reduced copy of it. The generated prologue measured the frame
by the bound texture, so either would have moved every crop, warp and
grain seed. Two uniform vec4s now say which part of the frame the texture
holds and how large the frame is; the sampler maps into the texture
through to_window, and a texture holding the whole frame samples exactly
as before ((uv - 0) / 1 is uv to the bit).
2026-09-27 17:34:00 -04:00
dtourolle 72aa7e98bf Let rawler decode a linear DNG wider than 16 700 pixels
rawler's allocation guard is sized in samples but worded in pixels, and
a linear DNG passes width × 3. A 22927 × 8966 Lightroom panorama was
refused as ">50000 px wide", and develop fell back silently to the
embedded preview. Route rawler through third_party with the guard at
1.5 G samples and 200 000 per axis.
2026-09-27 17:33:19 -04:00
dtourolle 77a1925bac Vendor rawler 0.7.2 unmodified
The crate as crates.io publishes it, minus .cargo-ok, its Cargo.lock and
data/testdata (13 MB of sample files only its own tests read). Not yet
routed through [patch.crates-io]; the next commit is the patch.
2026-09-27 17:33:19 -04:00
dtourolle 0d9feb0556 Describe Tone Mapping and the film's new place in the manual
The Light group gains `Tone Mapping`, and a film stock now takes its
place at the end of the chain rather than sitting after exposure (D19).
Say what the two sliders do and why every adjustment above them works on
the scene, highlights beyond white included. The pictures still show the
old rendering and are re-recorded separately.
2026-09-27 16:52:56 -04:00
dtourolle 0682d05f95 Let the tone curve continue past 1.0, and test that nothing clips
The tone curve clamped its input to [0, 1] and its output back to 1.0
around a 2.2 gamma, mid-chain — master and per-channel both. So any
edit with a curve made every scene value above 1.0 the same number
before the view transform ever saw it: D19's fourth finding. Beyond its
last point the curve now continues along its last span, whose secant
is the tangent the spline already gives that point, so the join is
smooth and an identity curve stays the identity to any height. The only
clamp left is the floor at zero, where there is no light to curve.

FR-DEV-2's acceptance test is how the rule stays true.
`scene_referred_until_the_view` wraps every point operation, at
non-neutral settings, between a gain of sixteen and one of a
sixty-fourth, with an identity in the view transform's place, and
renders a ramp from 0.4 to 15.2 times sensor saturation through it. The
output must still increase with the input and still separate the top of
the ramp. Run against the previous commit's tone curve it fails with
[21, 29, 33, 33, 33, 33, 33, 33]; every other operation already passed.
It renders on a device because dr-pipeline has none, and the spec's
pointer to it says so.
2026-09-27 16:52:55 -04:00
dtourolle 657f8f19ff Develop the film last, in the view transform's place
A film stock ran at order 25, after white balance and exposure, and
everything after it — contrast, the curves, the colour mixer, the
grading, every mask layer's tone — acted on the film's display-referred
output, as though the frame had been scanned and then worked on. That
was a display-referred rendering in the middle of the chain, which D19
removes (FR-DEV-3f, FR-DEV-3j).

`film_sim` is now in `Stage::View` beside `view_transform`. While a
stock is loaded the composer emits it in the view transform's place and
not the sigmoid; otherwise the sigmoid. So every edit is a decision
about the exposure the negative receives, and the film is the last
thing that happens to the picture — after the detail stage too, in the
view pass, which already binds the film's tables, the mask array and
the grain's source position. Its per-layer settings blend there as they
did in the fused pass.

`op_renders` goes: a rendering is chosen, not suppressed, and the only
render with no view transform is the camera-space tap. The YAML order
moves to 190 so the panel reads in pipeline order; the stage, not the
number, is what places it.

Existing edits that combine a stock with tone or colour operations now
render differently: those operations used to act on the print, and now
act on the scene.
2026-09-27 16:52:55 -04:00
dtourolle c07f81edcb Run the view transform after the detail stage, in a pass of its own
The fused pass stops at "linear working values" when a sharpener, a
blur or a repair follows, and the detail passes convolve what it hands
on. Until now it handed on the rendering: the base curve, and since the
last commit the view transform, ran before the store. So every kernel
worked on display-referred values while its comments promised the
opposite — D19's second finding.

A fused pass composed for a detail stage now stops before the view
transform, and carries a second shader, `ComposedShader::view`, composed
from the same inputs. It runs the same prologue, for the positions a
fragment reads (a film's grain seeds from `source_px`) and the corners
it blacks out, takes its colour from the detail stage's result bound
where the sample cache would be, and runs the view transform, the
output transform and the mask reveal. `render_detailed` dispatches it
after the last detail pass, in the same encoder.

So no detail pass encodes any more. Every pass writes an intermediate,
the last one included, which retires three things that existed only to
make the last pass encode: `writes_output` and the runner's second
layout, the body-less resolve pass for an active kernel with nothing to
draw at this scale, and capture sharpening's pass-through, which now
emits no pass at all. An empty chain is a whole render: the view pass
reads the fused result directly. The detail stage no longer takes an
output space either, so `compose_detail_for` folds into
`compose_detail` and the space is named once, on the fused half.

The cost is one full-render read and write per frame when a detail
stage exists, and a third intermediate for a one-pass chain.
2026-09-27 16:52:54 -04:00
dtourolle 92afaebd34 Make the view transform an operation the photographer can set
FR-DEV-3j gives the view transform two controls, contrast and the white
point in stops above middle grey, persisted and held per mask layer like
any other setting. A scene-referred pipeline whose white point cannot be
moved hands the photographer a shoulder they cannot place.

`view_transform` is a hand-written node in `Stage::View`, a new stage
the composer emits last and emits whatever the node's state: a neutral
operation is otherwise left out of the shader, but a photograph with no
view transform is a scan. "Active" keeps meaning "moved from the
defaults", so an untouched photograph writes nothing for it and
`every_node_starts_neutral` still holds. A caller whose chain holds no
view operation gets a default one.

The composer's loop becomes an ordered list of steps — camera nodes, the
matrix, scene nodes, layer-only nodes, the view — so each is emitted in
exactly one place. The base curve could not be a node because it
belonged to the camera; the ops README records why that argument went
with it (D19). The panel shows it in the Light group as "Tone Mapping".

Tests that counted the blocks of a neutral graph now count one, the
view transform, and the two chain-wide tests that load a film expect the
view transform to be absent, since a stock replaces it.
2026-09-27 16:52:54 -04:00
dtourolle 37a6d99dc4 Replace the per-body base curve with a scene-referred view transform
The base curve was a five-point spline on the unit square, flat past its
last point: every value above 1.0 left it as the same number, per
channel. Exposure and highlight recovery put values up there, and the
curve threw them away, then handed the result on as though it were
still scene-linear. The six per-body curves were also, by their own
file's account, hand-tuned shapes rather than measurements, and not
enough is known about where they came from to keep them (D19).

In their place, one view transform for every body (FR-DEV-3j): a
log-logistic sigmoid per channel, with the middle channel put back
between the other two so a hue survives the shoulder. Its two free
constants are solved from two conditions rather than set: scene grey
0.13, where the retired default curve put it, lands on display 0.18,
and the scene white four stops above grey lands on 1.0. So a highlight
a stop past sensor saturation still rolls into white, and the midtones
stay within 0.26 EV of the retired default between scene 0.03 and 1.0.
`dr_pipeline::view` holds the CPU reference and the WGSL, and the tests
there are FR-DEV-3j's acceptance criteria.

It is still fixed and still in the fused pass's tail, so a detail stage
still sees rendered values; the next commits make it an operation and
move it after the detail stage. It is skipped for a JPEG, as the base
curve was, and absent from the camera-space tap.

The base curve's database, its lookup and its twelve uniform slots go.
`RawImage` and `DemosaicedImage` lose the field, and the GPU test that
proved a curve reached the shader is replaced by one that renders the
view transform against the CPU reference and shows two highlights above
1.0 still render apart. The JPEG-and-sensor test now asserts the two
differ by exactly the view transform, where before an identity fixture
curve had made them match.
2026-09-27 16:52:53 -04:00
dtourolle db7b84795c Convert to the working space before the edits, not after
Every point operation ran in camera RGB and the camera matrix came after
them all, against the order ARCH §5.2 draws. So `luminance()` applied
Rec.709 weights to a body's own primaries, a band in the colour mixer
was a different hue on every make of sensor, and the vibrance skin guard
tested channel order in a space where skin does not have one.

Operations now declare a `Stage`. White balance is the only camera-stage
node — its multipliers scale the sensor's channels, and after a matrix
that mixes them the same numbers are a different correction — and says
so with `stage: camera` in its YAML, a key both the build-time generator
and the load-time declared op read. The composer emits the camera nodes,
then the matrix, then the rest, each group in graph order; an empty
chain still gets the matrix.

Film simulation stops converting out of camera space itself, since it is
now handed working-space colour like every other scene node. The base
curve stays where it was, after the operations, and so now acts on
working-space colour; the next commit replaces it (D19).
2026-09-27 16:52:53 -04:00
dtourolle e8898a5c38 Specify a scene-referred pipeline (D19)
The spec missed Ansel, and with it Aurélien Pierre's argument that a
display-referred curve early in the pipeline throws away what every
later stage needs. Read against that argument, the code broke it four
ways: the base curve was flat past its last point and so clipped every
recovered highlight; the detail stage was handed curved, clipped values
while its comments promised linear ones; the edits ran in camera RGB
because the matrix had drifted to after them, against ARCH §5.2; and the
tone curve clamped to [0, 1] around a 2.2 gamma mid-chain.

D19 records the decision: unbounded scene-linear colour from the matrix
to one view transform, last, after the detail stage. FR-DEV-3j specifies
that view transform as an adjustable operation (contrast, white) with a
default fitted to where the retired default curve put middle grey.
FR-DEV-3e retires the per-body base curves, whose own file called them
hand-tuned shapes of unknown provenance. FR-DEV-3f makes a film stock
the view transform when one is chosen, instead of a rendering at order
25 that the later edits acted on. FR-DEV-2 gains the acceptance test
that holds the rule, and ARCH gains §6.14 and the corrected §5.2 order.
2026-09-27 16:52:52 -04:00
dtourolle 621a6b8313 Leave a panorama frame out without leaving the page
A frame that did not fit ended the job with its name, and the only way
on was Back, a smaller selection and every frame read, demosaiced and
searched for keypoints again. Each row on the page now has a box. An
unticked frame is left out and the rest are solved again from what the
first pass measured.

dr_pano::align is split for it: match_pairs does the matching and the
pairwise RANSAC once over every frame (about 5 s for twelve), and solve
takes a subset of the frames and uses only the links among them (about
0.1 s). Solving a subset by re-aligning it also moved every RANSAC seed,
which are keyed on frame position, and on the fixture set that was
enough to lose a marginal link and strand a neighbour of the frame left
out.

A frame that cannot be placed no longer stops the job either. Its row
names it and Merge stays off until it is unticked; the headless example
leaves such frames out the same way, and takes --leave-out N to try it.
2026-09-27 16:29:39 -04:00
dtourolle e98b1def98 Keep blown highlights grey in a panorama
A clipped photosite reaches the merge as (1, 1, 1), which the as-shot
balance turns magenta. The preview balanced it with no highlight rule at
all, so every blown cloud was pink on the alignment page. The DNG had the
quieter form of the same fault: a frame's gain below one moved a blown
sample off the white level, and a feather mixed it into a neighbour's
real sky, so the develop's own desaturation no longer recognised it.

The merge shader and the preview now write a blown sample, before the
gain, as the camera value the composite's balance calls grey — the
develop pipeline's neutral, fading in from CLIP_ONSET.
2026-09-27 16:29:28 -04:00
dtourolle f52c4cb8b6 Open presets from a menu at the foot of the tool rail
Presets were a "Presets…" button in develop's top bar that opened a
sheet over the photograph, so applying one was two clicks with the
picture covered. They are now an entry pinned to the bottom of the tool
rail, apart from the tools because a preset arms nothing, and it opens a
menu beside the rail: the same sectioned rows the sheet lists, one click
to apply to the open photograph. The menu's last row, "Save or manage…",
opens the sheet, which keeps saving, renaming, reverting and importing,
since those take a name or a path.

The menu is a PopupWindow for the film list's reasons, with the list in
a clipped box of its own; the Flickable alone let its last row draw over
the manage row. Choosing from it zeroes preset-apply-count before
applying, since the grid may have left its selection count there.

The manual says where presets are now and gains a picture of the open
menu. The scenes reach the sheet through the menu and aim the manage
row from the rail's entry, as the film list's rows are aimed, because
the automation reports a popup's elements in the popup's coordinates.
2026-09-27 16:08:33 -04:00
dtourolle e22128c16b Release 0.18.2
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m34s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m0s
Build and test / Android (aarch64) (push) Successful in 30m28s
Build and test / android-image (push) Successful in 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 47m53s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 34s
Build and test / Windows (x86_64, cross) (push) Successful in 36m29s
Build and test / Publish the release (push) Successful in 1m10s
2026-09-27 08:23:04 -04:00
dtourolle 81ea9359bc Re-record the manual for 0.18.2
Every scene, recorded from this commit's desktop build. Since the last
recording (0.17.0) contrast flattens toward grey, a mask layer's
settings apply as offsets, and the film's settings are per-pixel, which
touch the develop, film, local and preset pictures; `--changed` could
not be trusted after the rebases, so nothing was left out.

Checked frame by frame: the film list still reaches Ilford HP5 Plus by
wheel, drag, scrollbar and keys (film_reach), and each picture shows
what its caption says. Settings shows version 0.18.1, the build's own
until the release commit bumps it.
2026-09-27 08:18:20 -04:00
dtourolle be37218696 Picture film inside a mask layer
The manual described film in a mask layer (6b99f67) with no picture of
it, and the paragraph named the control `Print exposure` where the
panel says `Print Exposure`.

A new scene, film_local, sets Kodak Ektar 100 on an upright alpine
frame — a negative, so it is printed and has a print exposure — then
adds a linear gradient, turns it by its rotate handle to fall from the
top, moves it onto the sky, shows the mask as its edge so the print
stays visible, and raises the layer's Print Exposure to burn the sky
in. Done Masking, then Before held. It puts the masks back to Tint
before undoing, as the other scenes expect.

The picture follows the film list's, under the paragraph it
illustrates. The bundled manual is regenerated to match.
2026-09-27 08:18:15 -04:00
dtourolle 5bff453d37 Add film in a mask and the hot-pixel repair to the README
The developing paragraph listed masks and film separately and said
nothing of photosites. It now says that a mask's sliders add to the
photograph's, the film's among them, so a sky can be burned in on the
print (0.18.1 and 6b99f67), and that hot and dead photosites are mended
before the demosaic with nothing to set (c50d96e).
2026-09-27 08:00:27 -04:00
dtourolle 61cd226719 Say that the runner's disk is the tighter budget
windows.md §7 weighed the Windows leg against the desktop leg in CPU
time only. What actually stopped a release was disk: the one 99 GB disk
is shared with the container images, v0.18.0's desktop run ended at
92 GB used, and v0.18.1's release build ran out, which is why that tag
has no release page. §7 now gives those figures, says what the desktop
leg deletes before its release build since 209ae361 and why that costs
the cache nothing, and names a second target's cache as the first thing
to check if a leg runs out again.
2026-09-27 08:00:27 -04:00
dtourolle 87d758bd81 Sweep outstanding.md for 0.18.2
Adds the release's line to the sweep notes at the head: FR-CULL-13's
write-path half and NFR-ARCH-1's executors, which now have entries, and
three things that closed without ever having had one — a mask layer's
film settings (FR-DEV-3f), hot and dead photosites repaired before the
demosaic (FR-RAW-3, with the defect-map reader still unwired), and the
tablet's scroll cue already described in §4a.
2026-09-27 08:00:19 -04:00
dtourolle 427a572aad Record where the named executors stand
NFR-ARCH-1's register note says what b1d1c472 and 7be1efff met and what
they left, but outstanding.md, which exists to show the distance between
the register and the binary, had no entry, and catalog.md §6 still said
interactive work runs "on the decode pool with the I/O pool behind it"
when there are no pools.

outstanding.md §4 gains the entry: named and guarded, not bounded — the
counts are a budget, the guard covers block_on only, the two mask
workers and the core crates' threads are outside the module. catalog.md
names the executors the thumbnail and metadata work starts on and says
the counts are not yet enforced.
2026-09-27 08:00:19 -04:00
dtourolle 01197ca37d Record FR-CULL-13's write-path half in outstanding.md
§2 still said FR-CULL-1 through -5 and -8 through -13 were built, while
the register's own status note (adbb9ac9) says only the write-path half
of -13 is met. The count now says -12, with -13 half built, and §2 gains
an entry for it: what verdicts.rs enumerates and holds to a reviewed
list, what fails the test, and the evidence that is not built — chips
for clipping, focus, burst membership and face counts, shown as absent
rather than zero, and a filter per signal.
2026-09-27 08:00:09 -04:00
dtourolle f9154ad12e Name the verdict check among the invariants the build defends
2cde2874 made `cargo test -p traceability` fail on any write of a
rating, flag, colour label or trash membership that is not on a
reviewed list, and CONTRIBUTING.md, which lists what CI will stop a
change for, did not mention it. It is now the third invariant beside
the ui-names-no-operation test and the operation schema: what it finds,
the kinds of reason a write can be listed with, that the workspace test
run is what runs it, and `traceability -- verdicts` to print the list.
2026-09-27 07:59:38 -04:00
dtourolle 1fdfb5990c Describe the film's tables as 0.18.2 bakes them
dr-film's README still described one 32³ lookup that took a negative
through the print and the paper, with the sliders' values baked into
it. Since 6b99f67 nothing a slider moves is baked: the curves are one
row per development time the datasheet measures and push interpolates
between them, a print is two lookups split at the paper's log exposure
with the enlarger's exposure added between them, and exposure, push,
print exposure and format reach the shader as uniforms. That is what
lets a mask layer hold film settings of its own.

The section now says so, and that the film's Exposure on the whole
photograph is the one setting that rebakes, because the enlarger's
filtration is solved against it.
2026-09-27 07:59:38 -04:00
dtourolle 7558053932 Say that a mask layer's settings add to the photograph's
fc54523 (0.18.1) stopped running a layer as a second chain after every
global operation and made its settings offsets applied at each
operation's own place, and nothing outside the code said so. The
manual still read as though a mask's slider were a setting of its own,
frame-budget.md described the masks as "a separate chain per layer",
and no document said how a layer is blended at all.

architecture.md §5.2 now says it: the offset, the blend by the layer's
weighted difference from the global result, what a photograph with no
layers composes to, and the film as the one operation whose settings
are averaged instead (6b99f67). FR-DEV-3 records the change as resolved
beside its local-adjustments bullet, frame-budget.md's note on what it
does not measure describes the cost as it now is, and the manual gives
the arithmetic in one sentence: -20 in a mask over -30 is -50 there.
The bundled manual is regenerated to match.
2026-09-27 07:59:29 -04:00
dtourolle 6bccc2db13 Say where hot and dead photosites are repaired
c50d96e added a repair pass ahead of the demosaic and no document said
so: FR-RAW-3's text names levels, the CFA and the colour matrices, and
architecture.md §5.2's stage diagram went from the upload straight to
black and white levels.

FR-RAW-3 now carries a status note: what counts as hot or dead, what
the photosite becomes, that Bayer and X-Trans share the pass, that
export and every other demosaicing path get it with no setting, the
test that holds it, and that the DNG defect map dr-decode can read is
still not used. §5.2 gains the stage and a paragraph on why it sits
before the demosaic, and its pointer to the raw histogram's tap names
the demosaic box rather than a row count the new stage would have made
wrong.
2026-09-27 07:59:00 -04:00
dtourolle 209ae36163 Free the desktop job's test binaries before its release build
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m18s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Successful in 47m36s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Layer separation (push) Successful in 32s
Traceability / Requirement traces (push) Successful in 1m37s
Build and test / Android (aarch64) (push) Successful in 30m39s
Build and test / Windows (x86_64, cross) (push) Successful in 35m42s
Build and test / Publish the release (push) Skipped
The single CI runner has one 99 GB disk shared with its container images.
The desktop job reached 92 GB used (2.1 GB free) on v0.18.0's run, and on
v0.18.1's the release build died with "No space left on device", so that
tag has no release page. At rest the runner holds about 23 GB; the
restored target cache, the models and the dependency build bring a job to
about 78 GB before a test runs, and tests plus the release build take the
rest.

The test executables and examples in target/debug are the largest part
of that, are relinked whenever a source changes, and are not what the
cache exists for — the dependency rlibs are. Deleting them after the Test
step, before the release build, frees several GB at the moment the job
is fullest without costing the next run anything the cache would have
saved. The step prints the disk afterwards, beside the existing Disk
before/after lines.
2026-09-27 07:25:49 -04:00
dtourolle 8071f7101a Say that a tablet's scrollers show a position cue
The manual said the lists on a tablet scroll by flick alone, and
outstanding.md §4a that Android had no scrollbars by design. Both now
describe the cue from #69: a thin line while the view moves, gone once
it stops, that a flick starting on it passes through; and §4a notes
DR_SCROLL_CUE for looking at it on a desktop, and that it is not yet
checked on the tablet. The bundled manual is regenerated to match.
2026-09-27 07:22:09 -04:00
dtourolle 502023c0f4 Show a thin scroll cue on Android instead of no scrollbar at all
On Android the desktop scrollbar is off, so every scroller that has one
on a desktop (the develop column, the grid, the collections sidebar,
Settings, the help sheet and the film list) gave no sign of how long it
was or where the view was in it. That is how the black-and-white film
stocks came to look deleted when the list could not scroll.

ScrollBar now has a second mode, chosen in the one Scrolling global:
where bars are off and `cue` is on, it draws the thumb alone, 3 px wide
against the edge, while the viewport moves, and fades it 500 ms after
the last move. It has no TouchArea, so a flick that starts on it
scrolls the content. Rust sets cue on touch-first builds; a desktop
build shows it when DR_SCROLL_CUE is set, to look at it without a
device. Desktop is otherwise unchanged.

Test: on the testing backend's help sheet, a press on the cue moves
nothing, a drag starting on it carries the list with the finger, the
cue is drawn while the list moves (drag, fling, wheel) and not once it
is idle; with bars on there is no cue and the thumb still takes a drag.
2026-09-27 07:22:09 -04:00
dtourolle adbb9ac9e6 Tag the judgement dispatch R7 and record which half of FR-CULL-13 is met
apply_judgement and apply_label carry TRACES: R7, the burst
representative callback FR-CULL-13 with a note that choosing it writes
the grouping and no verdict. The register's status for FR-CULL-13 says
the write-path test is met and the evidence chips are outstanding.
Traceability matrix regenerated.
2026-09-27 07:20:41 -04:00
dtourolle 2cde287440 Hold every verdict write to a reviewed list of user actions
FR-CULL-13 says evidence never writes a rating, flag, label or trash
membership, and nothing enforced it. tools/traceability/src/verdicts.rs
parses the shipped code with syn and enumerates every write: calls to
the catalog setters and trash recorders, SQL that assigns those columns,
sidecar Amendment::Judgement, and fields named rating/flag/label. Each
site must be in ALLOWED with a reason, as Input (inside a Slint on_*
closure, checked structurally), Relay (its callers are checked in
turn), Carried (a verdict made elsewhere: sidecar and XMP pulls, sync
merge, catalog mirrored to file, duplicates consolidation) or
NotAVerdict. Unlisted sites and stale entries both fail
`cargo test -p traceability`; `traces verdicts` prints the list.

syn and proc-macro2 were already in the lockfile as proc-macro
dependencies; this adds the edges, no new crate and no version change.
2026-09-27 07:20:38 -04:00
dtourolle 6e68f1fb3d Move a resaved test file's mtime ahead, so the scan cannot miss it
`a_resaved_file_owes_a_reread_and_loses_its_stale_hash` failed once in a
full dr-catalog run (updated 0, expected 1) and passed three times alone.
The incremental scan tells a changed file by its mtime at whole-second
resolution, and the `resave` helper wrote and renamed the file within
the same second as the scan before it, so on a fast enough pass the
resave looked like no change at all.

The helper now sets the file's and its folder's mtime two seconds ahead,
which is what a real resave some time after a scan looks like. Only the
test helper changes; the scan's rule is right for real files.
2026-09-27 07:19:04 -04:00
dtourolle 8df3dda9aa Regenerate the traceability matrix for the executors module 2026-09-27 07:08:40 -04:00
dtourolle 3912bc0d1d Point architecture §7.1 at the executors module and record NFR-ARCH-1
§7.1 now says where its table lives in code, how the UI thread is
marked and guarded, and that the counts are a budget rather than a
bound until work is pooled by executor. The register's status note says
what is met — named, spawned through one module, block_on guarded and
tested — and what is not: pooling, a guard for synchronous file reads
and catalog queries, the two mask workers, and threads the core crates
start.
2026-09-27 07:08:37 -04:00
dtourolle b1d1c47261 Start every worker thread through the executors module
Thirty-nine spawn sites in dr-ui, and one in the Android entry point,
called std::thread::spawn or a Builder of their own, and most of the
threads they started were <unnamed> in a panic message or a profiler.
Each now calls executors::spawn with its executor and a role, so the thread is
named <executor>:<role> — net:sync, decode:thumbs, io:catalog-open —
and knows which executor it is on. The three that already set a name
(automation, import, prefetch) keep their name as the role.

Behaviour is unchanged: each job still gets a thread of its own when it
starts, and spawn panics where std::thread::spawn did.

The module's documentation now says how a job is assigned: by what it
spends its time on, so a sweep that fetches bytes and then decodes them
is Decode, and a sidecar write that touches the catalog is Network.

Left as they were: the segmentation and refine workers in masks_ui.rs,
which another change is reworking, and test-only threads.
2026-09-27 07:08:37 -04:00
dtourolle 7be1efff32 Name the executors and fail a block_on on the UI thread
architecture.md §7.1 stated five executors and their thread counts, and
no code named them. dr_ui::executors now does: the Executor enum with
each one's thread name and the count §7.1 gives with its reason, and
spawn, which starts a thread named <executor>:<role> and marks it with
the executor it belongs to. The counts are the stated budget, not yet a
bound: a job still gets a thread of its own when it starts.

run marks its own thread as the UI executor before it builds the
window. net_runtime::build now returns a NetRuntime whose block_on
asserts, in debug and test builds, that the caller is not that thread;
everything else derefs to the tokio runtime. The login, folder-list and
remote-folder workers built the same runtime by hand and now take it
from net_runtime, so their block_on is guarded too.

Tests: a block_on on a thread marked as the UI executor panics naming
the UI thread; the same call on a worker returns; a spawned thread
carries its name and executor.
2026-09-27 07:08:37 -04:00
dtourolle c50d96e949 Repair hot and dead photosites before the demosaic
A hot photosite went into the demosaic as it was read, and came out as a
coloured cross three pixels wide that nothing later could take back
out. Night and long exposures showed them; the defect-map reader added
for FR-RAW-3 was never wired in, and a CR2 carries no map anyway.

A pass over the mosaic now runs ahead of the demosaic, into a second
buffer. A photosite is hot when it reads more than twice every
same-colour photosite in its 5x5 window plus 2% of the range, and more
than twice each of its eight immediate neighbours of any colour. The
second half keeps stars and glints: real light reaches the sensor
through a lens and an anti-aliasing filter and lights a patch, so the
photosites beside it are lit too, where a hot photosite's are dark. It
is replaced by its brightest same-colour neighbour, which invents
nothing. Dead photosites are the mirror case, judged only where the
neighbourhood is above 5%, so shadow noise clipped at black is left
alone.

The colour of each photosite comes from a 6x6 sensor-anchored tile, so
Bayer and X-Trans share the pass. Export and every other path that
demosaics get it too, and there is no setting: the repair only fires
where a single photosite disagrees with everything around it.

Cost, warm, on a Canon 6D frame (RTX 3050): 91-99 ms to demosaic
before, 94-98 ms after; the extra pass is inside the run-to-run noise.

Tests render a frame with and without the defect and compare the
finished pixels. Without the repair a hot photosite showed by 230 and a
dead one by 168; with it neither shows, and a 3x3 highlight at white
survives.
2026-09-27 06:20:23 -04:00
dtourolle 6b99f67f47 Develop a mask layer's film on its own settings
A layer offered the film's sliders and they moved nothing: its copy of
the node was never given the stock, so it stayed inactive. Film now
works in a layer the way the other adjustments do, as offsets to the
photograph's settings, but blended as settings rather than as results,
since a film is a rendering and cross-fading two developments is not
what a region on a pushed film looks like.

- dr-film bakes no slider. Exposure is a gain in the shader; push
  interpolates the stock's measured processes, one curve row each; the
  print is split at the paper's log exposure, so print exposure is an
  addition between two lookups and exact at any setting. The enlarger
  stays balanced at the photograph's exposure.
- film_sim reads all four settings as uniforms, format one-hot over a
  grain count per format, so every uniform is linear in what it does.
- Operation::blends_settings lets the composer average each overlapping
  layer's uniforms with the global ones by mask weight, the global
  setting taking whatever weight the layers leave, and run the fragment
  once. Three layers at full weight give the mean of their settings.
- The stock picker is hidden on a layer. Only the photograph's exposure
  re-solves the print balance; push, print exposure and format need no
  rebake at all now.
2026-09-26 23:29:13 -04:00
dtourolle 48c5e74fa8 Release 0.18.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m46s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 49s
Build and test / Android (aarch64) (push) Successful in 17m27s
Build and test / android-image (push) Successful in 3s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 25m32s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 29s
Build and test / Windows (x86_64, cross) (push) Successful in 37m19s
Build and test / Publish the release (push) Skipped
2026-09-26 21:29:26 -04:00
dtourolle fc54523093 Apply a mask layer's settings as offsets to the global ones
A local adjustment ran as a second chain after every global operation,
then blended by the mask. So global contrast -30 with -20 on a face was
contrast -30, the rest of the chain, and contrast -20 again on the
result, rather than -50 where contrast runs. The two edits compounded in
ways neither slider showed; a flattening applied to an already
flattened picture is how the shadows of a night shot went magenta.

A layer's setting is now an offset from its default, added to the
global setting (clamped to the parameter's range; a moved switch or
choice replaces it) and run at that operation's own place in the chain.
At each operation the global fragment and each touching layer's
combined fragment read the same input colour, and the pixel moves by
each layer's weighted difference: c_g + sum w_i (c_i - c_g). At full
weight that is the combined setting exactly, at zero the global result
exactly, and no setting is applied twice. An offset that brings an
operation back to neutral emits an empty version, which undoes the
global setting inside the mask.

Blending the colours rather than the uniforms is deliberate: the tone
curve and colour mixer emit code only for the channels and bands that
are touched, so the global and combined versions of one operation need
not share a uniform set.

A photograph with no masks compiles to the same shader byte for byte.

Test: global -30 with a whole-frame layer at -20 renders within one
count of global -50.
2026-09-26 20:43:02 -04:00
dtourolle 8392cf772e Flatten contrast toward grey instead of scaling shadows by a ratio
Reducing contrast turned every black in a night photograph pink. The
fragment lifted each pixel's luminance to its target by multiplying the
colour by target/luma. For a pixel at 0.001 on the way to 0.09 that is
a gain of ninety, and in the deepest shadows the channels are sensor
noise: after white balance the red and blue noise sits above the green,
their multipliers being nearly twice its, so ninety times that noise is
magenta.

Flattening now mixes the colour toward middle grey, which gives the
same luminance and adds the lift as a neutral. A black goes to grey and
its noise stays the size it was.

The same fragment clamped luma/0.36 into the curve's 0..1 domain, which
scaled every tone above twice middle grey down to 0.36 in either
direction: contrast +10 took a 230 grey to 162. Those tones are now left
where they are, which is continuous with the curve's top (value 1,
slope 0).
2026-09-26 20:43:02 -04:00
dtourolle 515d4eb59e Release 0.18.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m56s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 45s
Build and test / Android (aarch64) (push) Successful in 17m8s
Build and test / android-image (push) Successful in 2s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 48m1s
Build and test / windows-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / Layer separation (push) Successful in 36s
Build and test / Windows (x86_64, cross) (push) Successful in 21m35s
Build and test / Publish the release (push) Successful in 1m12s
2026-09-26 19:13:06 -04:00
dtourolle b2f3936a53 Say how synced faces and same-name people merge since #77 and #78
catalog.md §8.2 still said confirmed and rejected faces were matched to
local faces "by box", and that the merge reads only a remote face's box
and model; faces.md said `match_faces` "still matches by overlap alone
across devices". Since #77 the match falls back to embeddings, on the
photographs where a box leaves a remote face over, and since #78
`dedup_people` folds people of one name whose faces agree after every
sync. Both now say so, with the thresholds and the reason a less
decisive pair stays unmatched, taken from the code's own documentation.
2026-09-26 18:30:07 -04:00
dtourolle ec7a8c07ee Add a dedup_people example to measure the job on a catalog copy
`dedup_people COPY.sqlite` prints the listed people and faces before
and after, what the first run merged and kept apart and why, and the
time of three runs. The second and third runs are the cost the job
adds to every sync. `--peer PEER_COPY.sqlite` then plays two sync
round trips. The peer merges with the previous release's code path and
no job, this side merges back through sync::merge_remote, and the named
people each side lists are compared after every step.

On the reference pair: the desktop merges Claudine, Jessie x2, Mathias
and Noemi (80 -> 75 named). The tablet also merges its empty second
Ian (80 -> 74). The first run takes 1.2 s on the desktop, which is
building faces_box; the merge builds it first in practice. Later runs
take 8-15 ms.
2026-09-26 17:50:39 -04:00
dtourolle 78df4211b0 Run the people deduplication after every catalog merge (#78)
A merge is where two devices' people meet: the same name typed on each,
or a redirect one of them made. So dedup_people::run now follows every
successful sync::merge_remote. It runs on the sync worker, never the
UI thread, before the snapshot is pushed, so what it folds reaches the
server on the same pass.

It runs in its own transaction, and a failure is logged, not
returned. What the merge took is committed and valid either way, and
the next pass tries again. Once a catalog is clean it costs 8-15 ms on
the reference library (19k faces, 26k people rows). On copies of the
two real catalogs, a round trip with a peer running the previous merge
converges on 75 listed named people on both sides and stays there
over a second round. merge_remote with the job takes 75-90 ms there.
2026-09-26 17:50:38 -04:00
dtourolle c78b798cf0 Fold people of one name whose faces agree, and faces held twice (#78)
Seven names are two or three live people on both devices: Ian (756
confirmed faces, and a second Ian with none), Jessie three times,
Claudine, Mathias, Noemi, Pascal and PJ. Each was typed on its own
device and carried across by sync, which keys people on their uuid and
so keeps both. Each half of a person shows half their photographs.

dedup_people::run, in one transaction:

- Same-name people (trimmed, case-folded as the Identity screen folds
  them) merge into the one with the most confirmed faces, ties to the
  smaller uuid, through faces::merge_people_within, so confirmations,
  rejections and the survivor's name are kept. A person holding no
  faces at all merges: there is nothing to compare or to carry. Anyone
  else needs >= 2 confirmed faces per shared embedder on both sides and
  centroids at cosine >= 0.7 in each. A face confirmed as one and
  rejected as the other keeps them apart. Unnamed and set-aside people
  are never merged by name.
- Faces held twice (one image, one embedder, IoU >= 0.5, cosine >= 0.7)
  keep the stronger detector's row (FaceDetector::outranks), then the
  confirmed one, then the older. The survivor takes the confirmed
  assignment and both rows' rejections. A pair confirmed as two
  different people is left and counted.
- Judgements still on a merged-away person move to the person at the
  end of its redirects, and a redirect cycle (two devices merging one
  pair in opposite directions) is broken at the smaller uuid.

Measured on copies of the desktop catalog and the tablet's server
snapshot, w600k_mbf, confirmed faces only:
- Centroids of differently named people: 2,699 pairs, median 0.02,
  99.9th percentile 0.41. One pair reaches 0.70 (0.700 desktop, 0.705
  tablet), "Michelle Casanonve" and "Michelle Casanova", one person
  typed two ways. Next is 0.62/0.64, "Boris Jost" and "Boris". The
  highest pair that is plainly two people is 0.43/0.44.
- One person split in random halves: minimum 0.69, median 0.91 over 72
  people. Four faces against twenty-two reach 0.7 in 97% of draws.
  One face against twenty of somebody else's reached 0.74 in 3,000
  draws, and two faces reached 0.61, hence the two-face minimum.
- Pascal (22 and 4 confirmed) is at 0.57 and PJ (14 and 7) at 0.50,
  under 0.7 on both devices, so both pairs stay apart and are logged.
  The desktop's second Ian holds 4 suggestions and no confirmations,
  at 0.38 against Ian's centroid, and stays apart. On the tablet it
  holds nothing and merges.

Why a merge made here survives a peer on 0.17.0: the merged-away
person stays as a merged_into redirect with a bumped revision, which
the catalog merge has always taken on revision. The peer hides the
duplicate and never sends it back as a live person. Its own
confirmations of that person stay on the redirect, because a merge
never overwrites a local confirmation. The manual merge has always
left them there too. They follow the redirect when the peer runs this
job. A test syncs two catalog files through the previous merge code
and back, and the people converge and stay converged.

Once a catalog is clean the job reads 80 redirects, the named people,
and the face boxes from the covering faces_box index. That is ~10 ms
on the reference library. There is no schema change. The index is
created IF NOT EXISTS, as the merge already does.
2026-09-26 17:50:28 -04:00
dtourolle 6450f54199 Carry rejections when two people are merged
merge_people moved a person's faces onto the target and left the
"not this person" rejections on the redirect. A rejection there binds
nothing: once Annie is Anna, the grouping pass is free to suggest the
face the user pushed away from Annie as Anna, which is the behaviour
rejections exist to prevent. The Identity screen's merge has done this
since it was written, and the deduplication job for #78 merges through
the same function, so it would have done it for every same-name pair.

The source's rejections now move to the target (INSERT OR IGNORE, so
one the target already holds is not doubled). Where the two halves
disagree about one face, confirmed as one and rejected as the other,
the confirmation stands, as `confirm` already rules for one face; and a
moved rejection withdraws a suggestion of the same face, as `reject`
already does. Confirmations and names are unchanged.

The body is split into merge_people_within, taking the caller's
transaction, so a job that merges several pairs commits once
(unchecked_transaction cannot nest). merge_people keeps its signature
and its one transaction.
2026-09-26 17:41:55 -04:00
dtourolle 1479e45637 Keep one person to one face per photograph in the catalog merge
The grouping pass never puts two faces of one photograph in the same
group (the cannot-link in dr_face::cluster). The merge did not check
this. When the two devices disagree about which face in a frame is a
person, merge_people_within applied the remote's confirmation, or the
anchor of a set-aside group, to face X. This device already held the
same person on face Y of the same photograph, so the person ended up on
both faces.

The reference library has 80 such person/photograph pairs on the
desktop and 89 on the tablet: 79/88 unnamed set-aside groups and one
named person confirmed on two faces. There are no duplicate faces (no
pair of faces in one image and embedder with IoU >= 0.5).

An incoming assignment is now refused when another local face of the
same photograph already holds that person. The one exception is an
incoming confirmation against a local suggestion: the suggestion is
withdrawn and the confirmation is applied. Two confirmations stay as
this device has them, the same rule as a local confirmation outranking
a remote one. A face that already holds the person is not a rival to
itself, so a steady-state pass is unaffected. On the reference pair
this refuses 0 assignments and writes the same 8,954 as before; it only
changes what a future disagreement does. The refusals are counted in
MergeReport::faces_one_per_photograph.

Existing pairs are left alone. They are two different faces (cosine
0.31 for the named one), not one face twice, so there is nothing to
fuse, and which face is the wrong one is not the merge's to guess.
2026-09-26 16:54:05 -04:00
dtourolle b5b30e3750 Match synced faces by embedding where the boxes cannot (#77)
On the reference library, 631 of the faces in the tablet's snapshot
match no desktop face by box (IoU >= 0.5), so a name on them stays on
one device. Twenty of those are the same face with the box drawn
somewhere else. Whole photographs sit at IoU 0-0.48 with cosines of
0.72-0.96 between the two devices' vectors, and ten of them already
carry the same person on both sides. The merge never read the
1 KB embedding every face row carries.

match_faces now runs two passes. The box pass is unchanged except that
a pair must now be unique on both sides: a remote face with two
overlapping local faces, or a local face overlapped by two remote ones,
is no longer settled by whichever overlap is larger. Only for the
photographs where a remote face is left over, and a local face is still
free, does it read vectors: one json_each statement per side, keyed by
row id. That was 357 photographs on the reference library, not all
19 MB of vectors. A left-over face pairs with the local face it
resembles most when:
- the cosine is >= 0.7,
- the two are each other's best,
- each leads its runner-up by >= 0.2, and
- a box has not already claimed the local face.
Anything less decisive stays unmatched, so a new face stays new.

Why the threshold is safe, measured on both catalogs (w600k_mbf):
- Of 169,548 pairs of different faces in one photograph, 4 reach 0.7
  (lookalikes in one frame) and the maximum is 0.82.
- At 0.6 the rule would claim two pairs that carry different people on
  the two devices. At 0.7 it claims 20, none contradicted and 10
  corroborated, each leading its runner-up by more than 0.5.
- It only compares faces the boxes left unmatched on both sides: 737
  such pairs, so about 0.02 false pairs expected.
- A low cosine never overrules a box. About 150 box-matched pairs fall
  below 0.45, because two detectors cut the same tiny face differently.
  73 of them carry the same person on both devices.
- Faces are compared only within one file_id and one embedder, because
  the same person in another photograph reaches cosine 1.0.
- `Embedding::cosine` refuses a comparison across models.

Before -> after on the reference pair: matched by box 18,348 -> 18,348,
by embedding 0 -> 20, unmatched 631 -> 611, ambiguous 0 -> 0. The
report counts the embedding matches. No schema change.
2026-09-26 16:54:05 -04:00
dtourolle 884b681c21 Time a merge with another device's catalog in catalog_bench
The bench merged the catalog with a copy of itself. Every face in that
merge matches its own box, so the pass never reaches the faces the two
devices disagree about. On the reference library that is 631 of the
tablet's 19,052 faces, and it is the work #77 adds to.

`--remote PEER.sqlite` now also times `merge_remote_catalog` against a
copy of the peer file, and prints the first pass's report so two builds
can be checked for agreement. The first run writes what the peer
brought. The runs after it are the steady state, so compare builds from
two fresh copies of one catalog.
2026-09-26 16:54:05 -04:00
dtourolle 23abfd1827 Ship four presets for a bluer sky
Blue sky, Deep blue sky, Polariser, and Blue sky with golden land, in a
Skies section after Essentials. Each darkens the colour mixer's azure and
blue bands and adds chroma to them — what a polarising filter does to a
clear sky — and brings the highlights down with it, so a white cloud does
not read as a cut-out against the deeper blue. The stronger ones nudge
azure towards blue and add dehaze.

They are looks and work only on the hues a sky occupies, so an overcast
frame is left nearly alone: there is no blue for them to deepen, and
tinting grey cloud blue would be worse than doing nothing. Tuned by eye on
the demo library's alpine and Manhattan frames, with an overcast Étretat
frame as the control.
2026-09-26 16:46:46 -04:00
dtourolle 94ea2569ee Tag the SAF export path FR-EXP-10 only, not FR-PLAT-AND-1
saf.rs and the export path's SAF branch shipped tagged FR-PLAT-AND-1,
and the matrix counted the requirement as covered. Its subject is the
library — reached through SAF grants — and Android still reaches a
library over a Nextcloud account or a folder path. What the SAF code
does is give an album a folder on the tablet, which is FR-EXP-10.

outstanding.md said the figure overstated it and should be read with
this one subtracted; it now says the tags were narrowed, and coverage
reads 161 of 192.
2026-09-26 16:19:55 -04:00
dtourolle 7a56d16df1 Count the whole library in "All photographs" whatever is scoped (#76)
The sidebar's "All photographs" row was bound to library-total, which
is the scope's count: the header's "412 images" and the scrollbar's
size. Under a collection, and now under an album, the row read as the
album's size — 4 where the library holds 70.

It reads a separate library-whole-total now: the same number as the
scope's when nothing is scoped (no second count), and otherwise the
unscoped count under the same filter, read only when the view's facts
move, so scrolling inside an album does not recount the library.
2026-09-26 16:19:54 -04:00
dtourolle 5baaf9bac2 Release 0.17.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m22s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m1s
Build and test / Android (aarch64) (push) Successful in 32m18s
Build and test / android-image (push) Successful in 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 50m25s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 31s
Build and test / Windows (x86_64, cross) (push) Successful in 38m0s
Build and test / Publish the release (push) Successful in 50s
2026-09-26 16:18:18 -04:00
dtourolle 7428f6f845 Re-record the manual for 0.17.0, with albums and the new preset sheet
Every scene is recorded again on the 0.17.0 build, because the header
(Export to Exports), the sidebar (Albums) and the develop column had
all moved. The launch pictures showed the typed folder field, and the
export settings "Export to"; the presets picture predated the sections.

New scenes: presets_film scrolls the sheet down through the shipped
sections and applies Ilford HP5 Plus, a look that changes the film and
nothing else; albums exports four New York frames to an album, selects
it to show the originals behind its files, and opens a new album's
sheet before deleting what it made. record.sh now points the profile's
old export folder at DR_HOME/Exports, which the app turns into the
album "Exports" on first open - the one way to have an album without
the portal's dialogue, which Xvfb cannot show. The launch scene signs
out of a remembered folder instead of typing one, so it shows
"Open folder" beside the folder used last.

Not recorded: the server browser's New folder, which needs a Nextcloud
server, and the download screen, which a folder library never reaches
(the original is a local read, over before the first poll). The manual
says so where it describes each. film_reach and duplicates passed;
inference was pinned to the CPU.
2026-09-26 15:15:51 -04:00
dtourolle 9f95d23ff4 Say that no workflow builds the Flatpak yet
NFR-COMPAT-2's table has CI building every channel, the Flatpak
included. No workflow in .gitea/workflows/ builds it, and the sweep
before this one records that none has been built by hand either. A
status note under the table says so, so the decision and the state are
not read as the same thing.
2026-09-26 14:58:46 -04:00
dtourolle 1b8d0e740f Say the benchmark's second open no longer pays the backfill
benchmarks.md and catalog_open.rs both said schema::backfill runs on
every Catalog::open. Since ffdd640 it runs on the first open of a path
in a process and is skipped while the stamp matches, so in dr-bench
catalog_open_ms still includes it and catalog_open_warm_ms, the second
open in the same process, no longer does. That is what a library
reopened in one session costs, and both now say which figure is which.
The comment keeps its line count, so no tag below it moves.
2026-09-26 14:58:20 -04:00
dtourolle d2b99bb8c1 Note the folder dialogues in windows.md
Since 6683c14 every folder on the desktop is chosen through rfd, which
on Windows is the common item dialogue. windows.md's list of what is
already handled now says so, and that nothing has opened one under
Wine or on Windows. "The import flow's path picker" is now the import
page's Browse… button.
2026-09-26 14:57:35 -04:00
dtourolle f39b88b005 Bring the README, the docs index and CONTRIBUTING up to 0.17.0
The README said export went "to a folder here or back into the
library", which albums replaced and forbid, and called presets "named
presets" beside a shipped collection of film looks and Lightroom
imports. It now says both, and that a photograph only on the server
opens on its thumbnail with its download's progress. The requirement
count comes from traceability.md's summary: 192, 84% claimed; the
version and release count are left to the release commit.

docs/README.md's manual row gains presets and albums. CONTRIBUTING
said 177 requirements and 826 crates, and the Flatpak manifest 826
crates; the lockfile now holds 849 packages, 822 from the registry.
2026-09-26 14:56:12 -04:00
dtourolle 2f5f2041ab Record dehaze's two passes in frame-budget.md
The figures are bee5c58's, quoted as that commit measured them: five
passes to two, 22.9 ms to 9.1 ms at 2560 x 1600 fit and 54.1 ms to
28.1 ms at 4K, the output bit-identical. Under its own heading and
status line, like the 2026-09-25 section, since this file was not
re-run for them.
2026-09-26 14:55:18 -04:00
dtourolle 0f7ea741d8 Record the open's backfill stamp and the first-use indexes in catalog.md
#75 and the develop-landing work changed how the catalog is opened and
queried without touching its design document. §2 now lists the two
indexes made on first use, keywords_term_version and faces_box, beside
the tables made that way, and says why each exists. A paragraph states
what the backfill stamp in backfilled.rs holds, why it records the
newest rows by content rather than by id, and when the backfill still
runs.

§6.2's example of coalescing was a thumbnail job, a kind nothing
enqueues since #73.
2026-09-26 14:55:08 -04:00
dtourolle dee509c6ef Describe albums, the folder pickers and download progress in the designs
storage.md's trait listing stopped at get; it now has get_reporting,
what the default and the Nextcloud override do, and where develop reads
the figures. A new §5.3 says how folders are chosen — the portal or
Windows dialogue, the server browser whose New folder is create_dir,
SAF on Android — and where an album's files go: a server folder relative
to the account root, with the outbox's third .dest line, or a device
folder that never syncs.

catalog.md §8.2 said only collections merge, which had not been true
since keywords, people and capture metadata joined them, and is less
true with albums; it now lists what merges and why album_folders does
not. §2 records that the album tables, like dedup_probes, are made on
first use rather than by a migration.

outstanding.md said there was no SAF code on Android. There is now,
for album folders only, and it carries TRACES: FR-PLAT-AND-1, which
the entry says overstates a requirement about the library; FR-PLAT-AND-2
and S10's row follow from that.
2026-09-26 14:54:46 -04:00
dtourolle caaae11d98 Say the folder dialogue is the portal, and what the Flatpak has not proved
distribution.md §4, outstanding.md's FR-PLAT-LIN-3 entry, the Flatpak
manifest's comment and the README all said a library was chosen by
typing a path and that nothing in the tree called the FileChooser
portal. Since 6683c14 every folder the desktop asks for is chosen
through rfd's xdg-portal backend, so those sentences were false.

What they now say instead is narrower than "it works in the sandbox":
no Flatpak has been built here, so whether the portal's path opens a
library, holds across a restart and takes a sidecar is unobserved, and
volumes() still cannot see a host card. The chooser also landed in ui/
rather than behind the dr-plat seam distribution.md had proposed, and
both documents say so. FR-PLAT-LIN-3 gets a status note to the same
effect.
2026-09-26 14:52:44 -04:00
dtourolle e57c5b8182 Regenerate the traceability matrix after the rebase 2026-09-26 14:26:10 -04:00
dtourolle 02ddce8d80 Stamp the backfill on the newest rows, not only their ids
The backfill stamp read max(id) of images and versions and max(rowid)
of keywords. None of those tables is AUTOINCREMENT, so SQLite hands a
freed newest id out again: empty the trash of the newest photograph and
scan a new one, or let a local folder's walk delete a renamed file's row
and insert the new name in the same pass, and the new image takes the
old id. max(id) does not move, nor does count(*), and when a newer
version elsewhere keeps max(versions.id) still too, the stamp matched
and the open skipped the backfill.

That row is exactly one that needs it. Neither scan path creates the
default version: scan::persist and walk insert the image and leave the
version, the RAW/JPEG pairing and the keyword terms to the next open.
Skipped, the image went without them until the app restarted, so a
rating or a pulled sidecar judgement had no version to land on and a
JPEG beside its RAW showed twice.

The stamp now carries the newest row's content: the newest image's id,
path, added time and whether it has a version; the newest version's id
and image; the newest assignment's rowid, version and word. Whether the
newest image has a version is the part that cannot be fooled - after a
backfill every image has one, and a row that has just taken a freed id
has none - so the two stamps differ even when the same file comes back
at the same id in the same second. Still one statement: three reverse
rowid scans that stop at the first row, and one probe of versions_image.
An open that skips still costs ~1 ms on the reference catalog copy.

This closes the hole in the stamp itself rather than by a forget() at
each delete site, so a delete path added later, or one in another
process, cannot reopen it. Two tests delete the newest image and insert
another at the freed id on a separate connection, with a newer version
elsewhere holding max(versions.id); both fail against the old stamp.
2026-09-26 14:26:10 -04:00
dtourolle faf52f6dbd Ask the prefetch's cache questions on one held connection
holds_original, the prefetch worker's check that a neighbour's original
is already cached, opened the catalog for every neighbour it asked about.
Its own comment called it a row check; the open around it was four of
the five opens a develop landing made.

The worker now keeps one Catalog for the batch it is serving, opened at
the first check and reopened only if the batch names another catalog
file. The connection runs in autocommit, so each check still sees what
fetch_original committed in between. fetch_original is unchanged.

With the backfill no longer run on every open, a landing whose
neighbours are all cached goes from five opens to two, and from ~80 ms
of CPU to ~1-2 ms on a copy of the reference catalog.
2026-09-26 14:26:10 -04:00
dtourolle ffdd640170 Backfill the catalog once per state, not on every open
Catalog::open ran schema::backfill every time, and every worker thread
opens its own connection. A develop landing made five opens, and each
paid the RAW/JPEG pairing, the default-version anti-join over every
image, the uuid pass over every default version and the keyword check:
17 ms of CPU an open on a copy of the reference catalog, ~80 ms a
landing, to confirm that nothing had changed since the open before.

Everything the backfill repairs is a row some write added: an image a
scan inserted, a version or keyword assignment a merge brought in. So
the open now reads a stamp - user_version, max(id) of images and
versions, max(rowid) of keywords, and the file's device and inode - and
skips the backfill when the stamp matches the one recorded at this
path's last backfill in this process. The maxima are each the last page
of a b-tree; an open that skips costs ~1 ms.

The backfill still runs:
- on the first open in a process (nothing recorded yet);
- on any open that migrated the schema, unconditionally;
- after a pull: merge_remote forgets the path, so the next open
  backfills even when every incoming row collided and nothing moved;
- when the file is replaced under its name: the inode is in the stamp,
  and recovery::set_aside, the first step of a restore and a rebuild,
  forgets the path;
- when another process or thread adds rows, because the stamp is read
  from the file, not from anything this process did.

The stamp is taken before the backfill, not after. Read after, it would
describe the backfill's own inserts, and could record an image another
connection inserted in between as covered when it was not. Read before,
the worst case is one redundant pass after a backfill that did real work.

Kept in memory rather than in the catalog: a stamp row would need a
table an older build does not have and would travel in the sync
snapshot, where a flag from another device's catalog says nothing about
this one. No schema version bump, so the tablet on 0.16.0 still reads
the snapshot. Tests cover the skip, a scan's new image, a migration, a
pull and a replaced file.
2026-09-26 14:26:10 -04:00
dtourolle 2226d543f9 Time a develop landing in catalog_bench
Landing on a photograph in develop opens the catalog once to fetch the
original and once more per prefetched neighbour to ask whether the cache
already holds it: five opens, each running the whole backfill. The bench
timed one open but not the landing, so the cost of the shape was not
visible and a fix to it could not be measured.

Two figures now, both against an empty cache so the question is asked the
same way whatever the answer: the five-open shape the app had, and the
two-open shape where the prefetch worker keeps one connection for its
batch. On a copy of the reference catalog (23,582 images) under load, the
five-open landing costs ~80 ms of CPU.
2026-09-26 14:25:46 -04:00
dtourolle bee5c5866f Erode dehaze's window in one pass per axis, and recover in the second
Dehaze cost 22.9 ms of a 2560x1600 frame on the reference laptop RTX 3050,
and 54.1 ms at 3840x2160, with the memory clock held at 810 MHz by the power
cap (graphics 1762 MHz). It ran five passes: a run and a span erosion along
x, the same along y, and the recovery. At those clocks a detail pass costs
what it reads and writes, not what it taps: a pass with an empty body -
one render-sized rgba16float read and write - measured 4.0 ms, and each
dehaze pass 4.4-4.6 ms, so the taps were about 2 ms of the 22 and the four
hand-offs between passes were the rest.

Each axis is now one pass that takes the minimum over the whole window
directly, and the recovery rides in the y pass, which already holds the
veil and the pixel's own colour. That is 36 texture reads per pixel at
2560x1600 in place of 12, nearly all of them cache hits, and two passes in
place of five.

The picture is the same bits. A minimum is exact in any order, and the
window is the one Split always covered, the surplus pixel on the far side
included (Split::first and Split::width). The veil crossing the removed
hand-offs was already exactly representable in rgba16float - a minimum of
channels read from rgba16float, floored at zero - so storing it between
passes never rounded anything that the fused form now keeps unrounded.

Measured with a scratch probe that renders the synthetic 60 MP frame from
examples/frame_budget.rs, only a detail parameter moving so the fused pass
is reused, 30 frames per scene after six of warm-up, five runs of each
binary alternated, median of the per-run p50:

  scene                   before     after
  dehaze      2560 fit    22.88 ms    9.06 ms
  dehaze      2560 1:1    23.41 ms    9.52 ms
  dehaze      3840 fit    54.09 ms   28.12 ms
  all detail  2560 fit    53.11 ms   39.97 ms  (NR, sharpen, clarity,
  all detail  2560 1:1    67.48 ms   56.42 ms   texture, dehaze)
  every op    2560 fit    57.59 ms   44.19 ms  (with film)
  every op    2560 1:1    71.83 ms   57.93 ms
  controls without dehaze (NR, sharpen, clarity, texture): within +-2%

The rgba8 output hashed identically before and after for every scene -
dehaze alone, all five detail operations, every operation with film, and
each other detail operation alone - at fit and 1:1, at 2560x1600,
3840x2160, 1917x1203 and 333x211: 64 of 64.
2026-09-26 14:18:42 -04:00
dtourolle 9b580c3720 Satisfy rustfmt and clippy on the album and folder picker changes
rustfmt over the files the albums work touched, and the album merge's
incoming row as a named struct rather than an eight-field tuple, which
clippy's type_complexity refused.
2026-09-26 14:13:54 -04:00
dtourolle 1abb18d972 Specify albums and pointing at folders, and describe them in the manual
FR-EXP-10 is the album: a named export destination beneath the
collections, whose folder holds only the exported files while the
catalog links each back to its original; how albums sync, why a device
folder does not, and why the tables are made on first use rather than
by a migration. FR-EXP-6 now says the destination is an album, never
inside the library, and that folders are chosen by pointing — the
portal or Windows dialogue, SAF's tree picker, the server browser —
each able to make a folder.

The manual's launch and export sections say the same in the words on
screen. Its pictures still show the 0.16.0 launch screen and export
settings; they are re-recorded with the rig, not edited by hand.
2026-09-26 14:13:53 -04:00
dtourolle 92d4b23bed Give an album a folder on the tablet, through Android's folder picker
Android's only export destination was the library on the server
(ExportTarget::available), because writing to the device goes through
the Storage Access Framework and nothing did. An album's folder on the
tablet is now chosen in the system's tree picker — which has its own
"Create new folder" — and exports are written into it with
DocumentsContract.

The picker answers through onActivityResult, and the main activity is
NativeActivity, whose result is not ours. FolderPicker is a translucent
activity that only asks: it starts ACTION_OPEN_DOCUMENT_TREE, takes a
persistable grant (a folder is chosen once and exported to for months),
leaves the URI in a static, and finishes. Rust polls it from a Slint
timer — one static call, rather than a registered native method and a
thread to deliver on.

Two things the first build on the tablet got wrong, recorded where they
are fixed:

- Our classes must be loaded through Context.getClassLoader(). The
  class of what ndk_context holds is a framework class from the boot
  loader, which reports every class in the APK as not found.
- What ndk_context holds is the application context, not the activity,
  and starting an activity from it throws without FLAG_ACTIVITY_NEW_TASK.

Saf.write creates the document (or, under Overwrite, reopens the one of
that name with "wt" so a shorter file does not keep the old tail) and
returns the name the provider actually gave it, since SAF renames on a
collision by itself; the album records that name. A tree URI reads in
the sidebar as its folder ("Pictures/Web"), not as a content:// string.
2026-09-26 14:13:53 -04:00
dtourolle 7cbcacc02e Export to an album instead of a folder in the settings
Export took a path typed into the settings page, or a folder inside the
library on the server. The first is how exports end up somewhere nobody
looks; the second put JPEGs into the tree a scan catalogues, where they
came back as photographs beside the RAWs they were made from.

The destination is now an album (FR-EXP-10), chosen by name in the
export sheet. Albums are listed under the collections in the sidebar;
"+" there, or "New album…" in the sheet, opens a sheet for its name and
its folder — on this device through the platform's dialogue, or on the
server through the browser with "New folder". A server folder inside
the library is refused, and the sheet says why. Selecting an album
narrows the grid to the photographs behind its files: library::Scope
is Collection or Album, and scope_clause is the one place the two are
spelled, which also retires the two copies of the collection predicate
total_images_scoped and read_cells_scoped had inlined.

A batch resolves the album when it starts, and refuses in words when
none is chosen, it has gone, or its folder is local to another device.
Each item reports the image it came from, and the files written are
recorded against the album in one transaction when the batch ends.

A server album lives outside the library, so its queued uploads are
relative to the account root. That is a third line in the outbox's
.dest record rather than a leading slash, because a record written
before albums may carry a stray slash and must keep the meaning it was
written with.

An export folder set before albums becomes an album called "Exports"
on first open, so upgrading does not lose where exports were going.
The old destination fields stay in ExportSettings so older settings
files still read.
2026-09-26 14:13:53 -04:00
dtourolle 2eb06b1064 Make a folder from the server browser
The in-app browser that chooses a library folder on the server could
only open folders that already existed, so a library, or an export
destination, that was not on the server yet had to be made in the
Nextcloud web page first. It now has "New folder": a name, then MKCOL,
then the parent listed again and the new folder walked into — a folder
somebody has just named is the one they mean to choose.

The listing is the server's rather than the name inserted locally: the
server may have normalised or refused it. A name with a slash, "..",
or nothing at all is refused before any request, because a folder
typed with a slash in it is a path the user did not mean.

remote_folders holds the two WebDAV round trips (list, make) off the UI
thread, with the answer delivered through a Slint timer, so the album
sheet can use the same browser.
2026-09-26 14:13:53 -04:00
dtourolle 6683c14b40 Choose folders in the platform's dialogue, not by typing a path
Every folder the desktop asked for was a text field: the library folder
at launch, an import's source and second copy, a preset folder brought
over from Lightroom. A typed path is how a destination silently becomes
a new folder nobody meant — one wrong letter three levels down and the
write succeeds somewhere the photographer will never look — and a field
cannot make the folder that is not there yet.

They now open the platform's own dialogue through rfd: the XDG desktop
portal on Linux, the common item dialogue on Windows. The portal rather
than GTK because it reaches the user's files from inside the Flatpak and
needs no GTK in a Slint application, and it draws whichever desktop's
chooser is running, "New folder" included. It is awaited on Slint's
event loop (spawn_local), so the window keeps drawing while it is open,
and parented to the window so it opens over it.

PathRow shows what is chosen, read-only, beside the button. Android has
no filesystem dialogue — only SAF, which returns document trees, not
paths — so there the same rows stay typed fields (Pickers.local-paths).

The launch screen keeps the folder used last on screen with "Open
folder" beside it, so reopening is one press. Presets get two buttons,
a folder and a single .xmp file, because no platform dialogue picks
"a file or a folder" in one go.
2026-09-26 14:13:53 -04:00
dtourolle 94542371f6 Keep albums in the catalog: export folders and what went into them
An album is a named export destination. Its folder holds only the
exported files; the catalog records, per file, the image it was
rendered from, so an album can show the originals behind its JPEGs
(FR-EXP-10).

The tables are created on first use (CREATE TABLE IF NOT EXISTS), the
way dedup_probes is, rather than by a schema migration: a new
user_version makes every older build refuse this catalog's snapshot at
sync, and the 0.16.0 tablet would stop merging collections, keywords
and people for a feature it does not have.

Albums merge as collections do: by uuid and revision, tombstones on
delete, exports as a set union keyed on the server's file id (content
hash for a folder library). A folder on the server lives on the album
row and syncs; a folder on this device lives in album_folders, which
the merge never reads and the upload snapshot drops, because a path or
a SAF grant on one device means nothing on another.

Exports are keyed on the file name, not the image: two crops of one
photograph are two files and two rows, and an overwrite re-points the
name at whatever wrote it last.
2026-09-26 14:13:28 -04:00
dtourolle 715fcf8512 Regenerate the traceability matrix after the rebase 2026-09-26 14:03:27 -04:00
dtourolle 49b7bc2f9d Build the upload snapshot without the face crops instead of stripping them
Each sync pass spent 0.8-2.0 s of CPU and 1.0-5.4 s wall on the upload
snapshot of the reference catalog (24k images, 18,871 faces), ahead of the
rest of the pass. The upload itself had been crop-less since the crops
moved to the face shards. The cost was in how it got that way. The backup
API copied all 158 MB of the catalog, 96 MB of it the ~5 KB JPEG crop on
every faces row. Then `UPDATE faces SET crop = NULL` rewrote 18.9k rows
and freed their overflow chains, and VACUUM rebuilt the file again. That
wrote the catalog about three times over to upload 50 MB.

The snapshot is now built rather than copied. An empty file attaches the
catalog, creates each table from the catalog's own sqlite_master and fills
it with INSERT ... SELECT, with faces.crop selected as NULL. Indexes,
triggers and views follow, and user_version, application_id, page size and
the WAL header flag are carried over. It all runs in one transaction on the
snapshot's connection, so the catalog is read as of one moment and
concurrent writers are serialised, not raced, as the backup API did. The
build journal is in memory with synchronous off, because the file is
scratch that is rebuilt every pass and quick_check'd before upload. Foreign
keys are off on that connection. The bundled SQLite enables them, and then
a multi-row INSERT into images scans images for children of each new row
(shadowed_by is a self-reference with no index), which cost 1.2 s alone.

Measured on a .backup copy of the reference catalog with catalog_bench,
old and new binaries back to back on a loaded machine:
  before  best 1.0-5.4 s wall, 0.84-1.98 s cpu, 49.8 MB
  after   best 0.40-2.1 s wall, 0.39-0.96 s cpu, 50.4 MB
With the machine quiet the new build takes 0.31-0.43 s.

What a receiving device gets is unchanged. It is the same schema, the same
rows and a NULL crop, which is what 0.16.0 already uploads and merges. The
merge reads only a remote face's box and model (merge::match_faces) and
never writes a local crop. No device adopts a downloaded catalog as its
own, and a fresh one takes faces and crops from the shards. There is no
schema bump, so older builds still merge it. NFR-R2 backups keep using the
backup API and keep their crops.

Tests: the snapshot matches the catalog in schema, row counts, pragmas and
WAL header. A leftover file is replaced. Merging a crop-less snapshot
carries a confirmed name across by box and leaves the local crop
untouched, and does so idempotently.
2026-09-26 14:03:27 -04:00
dtourolle a59f14c797 Describe shipped presets and looks in the manual and FR-DEV-6
The presets sheet now lists the shipped collection beside the
photographer's own, a copy under a shipped name overrides it, and
shipped and imported presets are looks that leave a photograph's own
corrections alone. The manual says so where it introduces the sheet,
and FR-DEV-6 states the rule. The sheet's screenshot (media/presets.png)
predates the sections and needs re-recording.
2026-09-26 13:44:32 -04:00
dtourolle e82190fdf2 Import Lightroom presets as looks
A Lightroom preset changes the settings it was saved with and leaves every
other one where the photograph had it. Imported as a whole edit, a preset
holding only a grade reset the exposure, white balance and noise reduction
it was put on top of — the opposite of what the photographer had in
Lightroom.

Imported presets now reach only the operations they name
(`Reach::Named`), the rule the shipped presets already follow.
2026-09-26 13:44:32 -04:00
dtourolle a7b090cf36 Ship presets with the application instead of seeding them
The six starter presets were copied into the photographer's own library
on a first run and were theirs from then on. That cannot grow into a
real collection: a copy is frozen at the release that wrote it, so an
improved preset reaches nobody who had the old one, and re-seeding would
overwrite a preset someone had tuned.

`dr_pipeline::bundled` now holds the shipped presets as `.drpl` files
compiled into the binary, in sections — Essentials (the former six) and
three sections of film presets, one per measured stock in dr-film,
printed on the paper its profile names — and never writes them to the
user's file. Every shipped preset is a look (`Reach::Named`), so applying
one keeps the corrections a photograph already has.

A name links a photographer's copy to a shipped preset. Saving over a
shipped name makes their version the one that name applies; it is listed
in the shipped section, marked as changed, and deleting it reverts to the
shipped one. Renaming it makes it one of their own and the shipped preset
reappears. Keyed on the name because that is what the photographer sees
and chooses by.

Copies an older first run seeded are forgotten on load where they are
still exactly as seeded — otherwise all six would list as changed and
stay frozen at their old values. A tuned one is kept and now overrides.

The sheet lists "Yours" first, then each shipped section, with headings.
Shipped rows apply and nothing else; a changed row offers Revert where
the photographer's own offer Delete. A dr-ui test checks every shipped
film names a stock this build can bake, on that stock's own paper,
because dr-pipeline does not link the profile database.

The film presets name stocks by id; the measurements behind them are
spektrafilm's (CC BY-SA 4.0), attributed in each file as in dr-film.
2026-09-26 13:44:32 -04:00
dtourolle 90c0695c05 Let a preset name its film, and let a look reach only what it names
A preset could not choose a film stock. The stock is a choice of material
rather than a parameter, so `Preset` — a map of `op.param = value` — had
nowhere to hold it, and "Portra 400, printed" could not be saved, copied
or shipped as a look. Worse, the film node's own sliders *were*
parameters: a paste moved one stock's exposure and push onto whatever
stock the target was on, and left the target's tables baked from the
values it had just replaced.

A preset now carries a `FilmRef` beside its parameters. It travels under
whichever scope carries the film node, so the stock and its sliders are
never split, and by the replacement rule every other parameter follows:
applied at that scope, a preset without a film develops the target
without one. `Preset::apply` returns the `FilmRebake` it owes, as
`EditGraph::set_state` already did, because this crate cannot bake a
stock; the develop session pays it before recording the step, and the
batch paste writes the stock into each sidecar through `film_for`. The
library file spells it `film =` / `film_print =`, as a sidecar does, and
an older build keeps those lines as ones it does not understand.

`EditState` keeps the film in its own field only: the parameters it
captures leave it out, so one edit has one place to say which stock it
is on.

Second, a preset now has a reach. Replacement is right for a copy of a
whole edit — "make these match" — and wrong for a look: a stock-only
"Portra 400" applied that way would put the photograph's exposure, white
balance and noise reduction back to default. `Reach::Named` replaces only
the operations a preset names (whole operations, so a look that sets the
blacks resets the whites beside them) and the film only if it names one.
Saved edits and the clipboard keep `Reach::Whole`; the line `reach =
named` is written only for the other, so existing libraries write the
same bytes.
2026-09-26 13:44:32 -04:00
dtourolle c6d4c1ba62 Regenerate the traceability matrix after the rebase 2026-09-26 13:29:34 -04:00
dtourolle ce6705be89 Merge synced face assignments against what is held, read once per pass
After 9cff677 the loop over the other device's confirmed and ignored faces
(13,000 on the reference library) still asked three cached statements per
face -- the person by uuid, the face's current assignment, and whether this
pair was rejected here. It was 51 ms of a steady-state merge.

The person is now resolved in the statement that reads the incoming rows,
by the local `people.uuid` key:

  SCAN fp
  SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
  SEARCH lp USING COVERING INDEX sqlite_autoindex_people_1 (uuid=?)

and the local `face_person` (16,800 rows) and `face_person_rejected` are
each read once into memory and looked up there. A write goes to the table
and to the map, so a second remote face matched to the same local face sees
what the first left, as it did when each face re-read the table. The
incoming rows are ordered by face id -- the order the table was already
walked in -- since which of two such faces is applied last decides the
answer. An inner join to `people` drops the rows the old loop skipped for
want of a local person, and the counts in the report are unchanged.

After: the loop 10-12 ms. The merge as a whole, with the two changes before
this, went from 228-231 ms to 135 ms best of 5, and every catalog table
checksums the same after the bench as after the old build's run.
2026-09-26 13:28:50 -04:00
dtourolle ae0281fedd Match synced faces from an index of their boxes, not from their rows
`merge::match_faces` reads every local face's box and model to pair the
other device's faces with ours. It took 54 ms of a steady-state merge on the
reference library (19,000 faces).

A `faces` row is eight kilobytes -- the embedding, the crop, the dense
landmarks -- and `model_id` sits past the embedding, so reading it opened
each row's overflow pages:

  SCAN f
  SEARCH r USING INTEGER PRIMARY KEY (rowid=?)

`faces_box (image_id, model_id, x, y, w, h)` holds every column the scan
asks for:

  SCAN f USING COVERING INDEX faces_box
  SEARCH r USING INTEGER PRIMARY KEY (rowid=?)

The local scan went from 38 ms to 8 ms (sqlite3 on a copy, aggregated so
output formatting is not timed), and `match_faces` from 54 ms to 30-37 ms;
what remains is the other device's half. That is read from its snapshot,
which has whatever indexes its build made -- this one will carry
`faces_box` in its uploads -- and whose rows have had their crops stripped.
The bench merges a full copy with crops, so it overstates that half.

Created on first use in `match_faces`, with CREATE INDEX IF NOT EXISTS,
rather than by a migration, for the reason `keywords::ensure_term_index`
gives: a schema version bump makes older builds refuse the snapshot, and an
extra index is invisible to them. The first merge after the upgrade builds
it (about a second, once). Its prefix duplicates `faces_image_model`, which
is left alone; the planner takes either for an (image_id, model_id) probe.
Tables checksum the same after the bench run as after the old build's.
2026-09-26 13:28:50 -04:00
dtourolle 981022ab1d Ask a synced keyword's tombstone once per merge, not once per assignment
`merge_remote_catalog` on the reference library (catalog_bench, a copy
merged with itself: the steady state of a sync pass) cost 228-231 ms best
of 5. Timing its phases put 91 ms in the keyword half, not in the faces the
issue named.

Both assignment unions refuse a word this device holds only as a tombstone,
with a correlated `NOT EXISTS (... deleted = 1) OR EXISTS (... deleted = 0)`
per incoming assignment. The `deleted = 1` half has no index to use --
`keyword_terms_name` is partial on `deleted = 0` -- so it scanned the whole
vocabulary for each of the 10,800 rows:

  SCAN rk
  CORRELATED SCALAR SUBQUERY 1
    SCAN t
  CORRELATED SCALAR SUBQUERY 2
    SEARCH t USING COVERING INDEX keyword_terms_name (name=?)

The refused words are one set for the whole statement, so it is asked once:
`rk.keyword NOT IN (tombstoned names EXCEPT live names)`, which is the same
condition -- refused exactly when deleted under some identity and live under
none -- and which SQLite builds as a list before the walk:

  SCAN rk
  LIST SUBQUERY 2
    MERGE (EXCEPT) ...

The file-id union alone went from 72 ms to 11 ms (sqlite3 on a copy), and
the keyword phase of the merge from 91 ms to 28-35 ms. Every table of the
catalog checksums the same after the bench as after the old build's run,
and the merge tests for tombstones and renames pass unchanged.
2026-09-26 13:28:50 -04:00
dtourolle 87badb6f99 Count the grid by subtracting the hidden burst frames, not probing per image
The grid's total is read on every scroll reload (`load_window` compares it
to notice a delete). On the reference library it cost 1.3-1.5 ms best-of-50
by catalog_bench, 2-3.6 ms on a busy machine, and the issue measured 4 ms.

`uncollapsed` asked every visible image whether a collapsed burst stands in
for it -- two primary-key probes per image, 19,000 times, on a library with
no bursts at all:

  SCAN i USING INDEX images_grid_order
  CORRELATED SCALAR SUBQUERY
    SEARCH bm USING INTEGER PRIMARY KEY (rowid=?)
    CORRELATED SCALAR SUBQUERY
      SEARCH be USING INTEGER PRIMARY KEY (rowid=?)

`total_images_filtered` now counts what the filter keeps and subtracts the
frames `bursts::collapsed_away_frames` lists, under the same filter:

  SCALAR SUBQUERY: SCAN i USING INDEX images_grid_order
  SCALAR SUBQUERY: SCAN bm; SEARCH be ...; SEARCH i USING INTEGER PRIMARY KEY

The second half walks only `burst_members`. Each image is in it at most
once (it is the key), and the filter is applied to both halves, so the
subtraction removes exactly the rows the predicate used to drop. The new
fragment sits beside `not_collapsed_away` in bursts.rs, and a test holds
the two to the same rows with bursts open and closed.

After: 0.3 ms, the same count (19,152). The cells query keeps the predicate:
it is a window with a LIMIT and needs the rows, not their number. The
rated grid count (3.5-4 ms with a one-star filter) is unchanged: its cost
is the rating subquery per image, and changing how `RatingFilter` spells
it changes every grid and timeline query, which is left for its own change.
2026-09-26 13:28:50 -04:00
dtourolle d537e4a965 Serve the keyword counts from an index that carries the version
`keywords::list` is the vocabulary with a per-word photograph count, and
`keywords::for_images` calls it on every selection change to redraw the
keyword panel. On the reference library (58 words, 10,800 assignments) it
cost 3.0-3.5 ms best-of-50 by catalog_bench, `for_images` 3.1-3.6 ms (6 and
5 ms on a busy machine).

Per word, the count walks `keywords_term (keyword)` and, for each
assignment, reads the `keywords` row to learn its version before probing
`versions` for the image:

  SEARCH k USING INDEX keywords_term (keyword=?)
  SEARCH v USING INTEGER PRIMARY KEY (rowid=?)

With `keywords_term_version (keyword, version_id)` the first step is
index-only:

  SEARCH k USING COVERING INDEX keywords_term_version (keyword=?)
  SEARCH v USING INTEGER PRIMARY KEY (rowid=?)

After: `list` 1.3 ms, `for_images` 1.5 ms, with the same answers (digests of
both outputs compared on the reference library).

The index is created on first use by `list`, with CREATE INDEX IF NOT EXISTS,
not by a migration: a new schema version makes every older build refuse this
catalog's snapshot at sync (`sync::remote_is_mergeable` compares
`user_version` and nothing else), and a build that meets an extra index
ignores it. Once the index exists the statement is a schema lookup, 8 us. A
failure to create it -- a read-only or busy catalog -- is logged and the
list is read without it, as before.
2026-09-26 13:28:50 -04:00
dtourolle fe6e523443 Count the originals on this device from the cache, not from every image
`library::local_original_count` feeds the "On this device" chip and runs
beside the rating counts on every star keystroke. On the reference library
it cost 1.3-1.4 ms best-of-50 (3 ms on a busy machine) to find 254
originals among 19,000 visible images.

It was a correlated EXISTS per visible image:

  SCAN i USING INDEX images_grid_order
  SEARCH ic EXISTS USING INTEGER PRIMARY KEY (rowid=?)

`image_cache` holds a row only for what has been fetched, so the question
is driven from it: `i.id IN (SELECT image_id FROM image_cache WHERE
tier_actual >= Original)`, which SQLite plans as the list first and a probe
of `images` by id for each entry:

  SEARCH i USING INTEGER PRIMARY KEY (rowid=?)
  LIST SUBQUERY 1
    SCAN image_cache

`image_id` is the cache's primary key, so each image is in the list at most
once and the count is the one the EXISTS gave (254). After: 0.05 ms. On a
library whose every original is cached this is as much work as before,
which is the proportion the rule asks for.

The count stays on the keystroke path: dropping it there would leave the
chip stale after a background download until something else refreshed it,
and at this cost there is nothing left to save. catalog_bench spells the
query as dr-ui does, so its copy changes with it.
2026-09-26 13:28:50 -04:00
dtourolle 73059f2656 Count the label chips from the labelled versions, not from every image
`label_histogram` runs on every label keystroke and after every batch of
judgements is saved. On the reference library it cost 7.2-8.4 ms best-of-50
by catalog_bench (13 ms on a busy machine), to report that none of 23,500
images carried a label.

The join was the rating histogram's, with one thing worse: the index does
not carry `label`, so each probe went on to read the version's row.

  SCAN i USING COVERING INDEX images_folder
  SEARCH v USING INDEX versions_judgement (image_id=?) LEFT-JOIN
  USE TEMP B-TREE FOR GROUP BY

It now takes the rating histogram's shape: only labelled default versions
are grouped, and the unlabelled slot is what is left of `judged_rows`.

  SCAN versions USING INDEX versions_judgement
  USE TEMP B-TREE FOR GROUP BY          (the labelled rows only)

That pass still reads each default version's row for `label`, but in the
index's order, which follows the table's; a partial index on the labelled
rows would make it index-only, and was not worth a new index for the
remaining 1 ms. After: 1.7-2.0 ms, the same answer on the reference library,
and a test that compares it with the old join over the awkward states the
rating test uses (a second default's label counted, unknown codes and zero
folded into unlabelled).
2026-09-26 13:28:50 -04:00
dtourolle 81118728f4 Count the rating chips from the rated versions, not from every image
`rating_histogram` runs on every star keystroke. On the reference library
(24k images, 1,200 of them rated) it cost 6.3 ms best-of-50 by
catalog_bench, and up to 10-14 ms when the machine is busy.

It was `images LEFT JOIN versions ON ... AND is_default = 1 GROUP BY
rating`. The plan:

  SCAN i USING COVERING INDEX images_folder
  SEARCH v USING COVERING INDEX versions_judgement (image_id=?) LEFT-JOIN
  USE TEMP B-TREE FOR GROUP BY

A probe of the index per image, then a sort of all 23,500 rows, to put
22,000 of them in slot zero.

Now the rated rows are grouped on their own (`rating != 0`: one pass over
`versions_judgement`, a sort of 1,200 rows), and slot zero is what is left
of the join's row count. That count is three index-only aggregates -- the
library size, the default versions, and the images holding one -- so an
image with no version is still unrated, and an image with two default
versions still counts twice, exactly as the join counted it:

  SCAN versions USING COVERING INDEX versions_judgement      (x3)
  SCAN images USING COVERING INDEX images_folder

`count(DISTINCT image_id)` has its own statement because alone it reads the
distinct values off the index order; beside other aggregates SQLite builds a
temporary b-tree for it.

After: 1.2 ms. The histogram is the same on the reference library
([22364, 663, 19, 47, 115, 374]), and a new test compares it with the old
join on a catalog holding every state the schema allows: no version, only a
virtual copy, two defaults, ratings below zero and above five.
2026-09-26 13:28:50 -04:00
dtourolle 408f189019 Measure what the library screen reads on each keystroke and scroll
Issue #75 lists catalog reads paid on interactive paths rather than once:
the rating and label chip counts on every judgement keystroke, the "On this
device" count beside them, the keyword panel's vocabulary on every
selection change, and the grid's total on every scroll reload. catalog_bench
now times each of them against a real catalog and prints their answers, so
a change to any of them can be checked for giving the same numbers.

Two of them live in dr-ui's private `library` module; their SQL is spelled
in the bench as it is spelled there, which the module comment says.

Reference library (24k images), best of 50, CPU, on a loaded machine:
rating_histogram 9.0 ms, local_original_count 2.0, label_histogram 10.0,
keywords::list 4.0, keywords::for_images 5.0, grid count 1.9, grid count
with a one-star filter 4.0.
2026-09-26 13:28:50 -04:00
dtourolle fabc1c5b56 Regenerate the traceability matrix after the rebase 2026-09-26 13:22:08 -04:00
dtourolle 441f6f1404 Record why thumbnails are not queued
catalog.md §6 still described the design of 2026-08-09, where the grid
enqueued Thumbnail jobs at Interactive and a runner drained them. It was
never built that way: the grid asks a worker directly and the sweep's
work list is what the thumbnail store lacks. The one enqueue that did
exist fed a queue nobody claimed (#73).

§6.1 now states the decision and its evidence: the store is shared
between devices and is the only record that knows a thumbnail exists,
metadata is owed through metadata_state the same way, the retired rows
are dropped at open rather than by a migration so no older device loses
the synced catalog, and every_queued_kind_has_a_consumer holds the rule.
§6.3 notes that the priority ordering is had without the queue.

outstanding.md's FR-PLAT-AND-4 paragraph said the scan's thumbnail jobs
were the one reachable enqueue; it now says nothing enqueues, and that
feeding the runner means a handler and its enqueue in the same change.

Refs #73
2026-09-26 13:15:18 -04:00
dtourolle b3dbf4a039 Refuse a job kind that is enqueued with nothing to claim it
The queue coalesces, so a producer with no consumer never fails: it
leaves one row per subject for ever. That is how 23,582 Thumbnail jobs
accumulated unnoticed (#73), and nothing at runtime would have said so.

every_queued_kind_has_a_consumer reads the shipping sources of every
crate under core/, ui/, apps/ and platform/ (cfg(test) items dropped)
and pairs the JobKind named at each enqueue( call with the kinds named
in a fn kinds( body or a claim_next_matching( call. An enqueue that does
not spell its kind is refused, since the pairing could not be checked.

It guards against passing over nothing: the queue's own files and the
scan must have been read. A second test runs the reader over fixed
snippets so a parsing bug shows up as a failure. Run against master's
scan.rs and walk.rs it names all three orphan enqueues.

Refs #73
2026-09-26 13:15:18 -04:00
dtourolle 6e67ef4467 Drop retired Thumbnail jobs whenever a catalog is opened
Stopping the enqueue leaves the rows already queued: 23,582 on the
reference catalog, about 1 MB of table and indexes that every query over
jobs pays for.

A migration would be the usual tool and is the wrong one here. A schema
bump makes an older build refuse the synced catalog snapshot, and the
tablet is on 0.16.0. So the rows are dropped at runtime instead, by
jobs::drop_retired over a new JobKind::RETIRED list, from runner::recover
- which already runs exactly once per catalog open, before any worker.

It runs every open rather than once because an older build sharing the
catalog queues them again on its next scan. kind leads the
UNIQUE(kind, subject_id) index, so with nothing left it is one index
probe. Measured on a copy of the reference catalog: 23,582 rows dropped
in 40 ms on the first open, 0.07 ms after.

Thumbnail stays in the enum so its number is never reused for a kind
that would then inherit old rows. The runner tests that call recover
move to a live kind; the jobs.rs tests of queue mechanics never call
it and are unchanged.

Refs #73
2026-09-26 13:15:18 -04:00
dtourolle 5fcd3752d7 Stop the local walk queueing work nothing claims
walk::scan_root enqueued an ExtractMetadata and a Thumbnail job for every
image it inserted or found changed. No handler claims either kind. The
walk is only reachable from the scan_local example today, so no real
catalog holds these rows, but it is the same leftover the remote scan
carried (#73) and it is what a local library would inherit.

Both debts are already recorded where their consumers look: an inserted
or changed image is written at metadata_state 1, which is the metadata
sweep's work list, and the thumbnail store answers for itself.

The tests that used job rows as the measure of "this image owes work"
now read metadata_state, which is the record the sweep actually uses;
the no-requeue test marks the first image read before the second scan,
so it still proves an unchanged neighbour is not put back in debt.

Refs #73
2026-09-26 13:15:17 -04:00
dtourolle 5da28584a4 Stop the scan queueing a thumbnail job per photograph
The reference catalog held 23,582 Thumbnail jobs, one per image, and
every scan re-coalesced all of them. Nothing has ever claimed that kind:
no JobHandler is registered for it on desktop or Android, and
dr_catalog::sync never merges another device's jobs in.

Thumbnails are owed by the store, not the queue. The grid's worker and
the thumbnail sweep both find their work by asking ThumbStore what it
lacks, and the store is shared between devices, so it is the only record
that knows another device already made one. A queue row was a second,
staler copy of that debt that grew with the library and was read by
nothing.

persist still writes the images and their remote identities in the one
transaction; it just no longer adds a row to jobs for each of them. The
two tests that asserted the rows existed become one that asserts a
repeated scan queues nothing.

Refs #73
2026-09-26 13:09:44 -04:00
dtourolle 8a1d9c8642 Find the canvas tools by the condition they are gated on now
The download fix renamed the canvas gates to root.has-photo, and the
canvas-order test still searched for the old spelling, so it panicked
before checking anything. The order it guards is unchanged.
2026-09-26 11:58:30 -04:00
dtourolle c3b10ed372 Format the download description test 2026-09-26 11:23:09 -04:00
dtourolle 4bec01eaf1 Say a photograph is downloading, and how far, instead of failing
The develop view reported a remote original on its way through the
error message, so it read "Could not load image" over "Downloading…".
It did so on every step along the roll, including a cached frame that
was ready within a tick, so each step flashed the error.

Waiting is now its own state. On the step, the grid's thumbnail of the
photograph stands in at once. Only when a transfer is really on the
wire does it dim under "Not on this device yet", with a line like
"Downloading — 12.4 of 38.0 MB" and a progress bar.

The bytes come from a new RemoteBackend::get_reporting. The Nextcloud
backend overrides it to read the body chunk by chunk; the default
reports once at the end. Progress is kept in the in-flight registry by
path, because a step usually lands on a frame the prefetcher is already
fetching. The catalog's file length stands in when the server sends no
Content-Length.
2026-09-26 11:02:11 -04:00
dtourolle 3b97195b37 Keep a stepped-past download from replacing the open photograph
Opening a photograph from the library starts a download and a timer that
polls for it. Every step along the roll started another, and each one put
its result on screen when it landed, so a frame stepped past earlier
could arrive last and replace the one whose name was showing. Each open
now takes a generation number; a download that lands for an older
generation is recorded in the activity list (its bytes are cached) and
goes no further.

The outgoing session also stayed live until the new download landed.
Its sliders kept working, and a second step before the first landed
saved that session's edit under the new photograph's identity. The
session is now dropped as soon as its edit is saved.
2026-09-26 11:00:36 -04:00
1190 changed files with 91834 additions and 5145 deletions
+13
View File
@@ -157,6 +157,19 @@ jobs:
- name: Test - name: Test
run: cargo test --workspace run: cargo test --workspace
# The runner has one 99 GB disk shared with its images, and this job
# ended at 92 GB (2.1 GB free) on v0.18.0's run; v0.18.1's release
# build then died with "No space left on device". The test executables
# and examples in target/debug are the largest things in it, are never
# reused (a changed source relinks them) and are not what the cache is
# for — the dependency rlibs are — so they go before the release build
# rather than competing with it.
- name: Free the test binaries before the release build
run: |
find target/debug/deps -maxdepth 1 -type f -executable -delete
rm -rf target/debug/examples target/debug/incremental
df -h /workspace 2>/dev/null || df -h .
- name: Build - name: Build
run: cargo build --workspace --release run: cargo build --workspace --release
+14 -4
View File
@@ -1,6 +1,6 @@
# Contributing to DarkRoom # Contributing to DarkRoom
There is a lot of documentation here — 14 documents and 177 numbered There is a lot of documentation here — twenty-odd documents and 192 numbered
requirements — and almost all of it is written for someone who has already requirements — and almost all of it is written for someone who has already
decided to work on this. This file is the other thing: how to get a first decided to work on this. This file is the other thing: how to get a first
change landed without reading any of it. change landed without reading any of it.
@@ -62,7 +62,7 @@ sudo apt-get install pkg-config libfontconfig1-dev libxkbcommon-dev
cargo run -p darkroom-desktop cargo run -p darkroom-desktop
``` ```
The first build resolves 826 crates and takes a while — on a laptop, long The first build resolves some 850 crates and takes a while — on a laptop, long
enough to look like a hang. It is not one. enough to look like a hang. It is not one.
Android is a containerised toolchain and is not needed for most work; see Android is a containerised toolchain and is not needed for most work; see
@@ -148,9 +148,9 @@ screen looks like, [`tools/manual`](tools/manual/README.md) says how to record
it again. The pre-commit hook regenerates the matrix, the gesture book and the it again. The pre-commit hook regenerates the matrix, the gesture book and the
page; CI runs all three checks. page; CI runs all three checks.
## Two invariants the build defends ## Three invariants the build defends
Worth knowing before you trip one, because both failures name a requirement Worth knowing before you trip one, because each failure names a requirement
rather than a line: rather than a line:
- **No operation may be named in `ui/`** (FR-DEV-3a). Special-casing one - **No operation may be named in `ui/`** (FR-DEV-3a). Special-casing one
@@ -162,6 +162,16 @@ rather than a line:
`order:`, a filename disagreeing with its `id:`, a default outside its own `order:`, a filename disagreeing with its `id:`, a default outside its own
range, an expression naming something that is not a parameter. Each error range, an expression naming something that is not a parameter. Each error
names the key you got wrong and exits rather than panicking. names the key you got wrong and exits rather than panicking.
- **No verdict is written without a user action** (FR-CULL-13). A rating,
flag, colour label or trash membership is the photographer's to set, never a
signal's. `tools/traceability/src/verdicts.rs` finds every write of one in
the shipped code — the catalog setters, SQL that assigns those columns, the
sidecar's judgement amendment — and holds each to a reviewed list with its
reason: inside a Slint `on_*` callback, writing for callers that are checked
in turn, or carrying a verdict made elsewhere, such as a sidecar pull or the
sync merge. A new write fails `cargo test` (the `traceability` crate's tests,
part of the workspace run) until it is listed, and so does a listed one that
has gone; `cargo run -p traceability -- verdicts` prints the list.
## Commit messages ## Commit messages
Generated
+183 -28
View File
@@ -347,6 +347,28 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"async-fs",
"async-net",
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.5",
"raw-window-handle",
"serde",
"serde_repr",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -385,6 +407,17 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]] [[package]]
name = "async-io" name = "async-io"
version = "2.6.0" version = "2.6.0"
@@ -414,6 +447,17 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]] [[package]]
name = "async-process" name = "async-process"
version = "2.5.0" version = "2.5.0"
@@ -1221,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]] [[package]]
name = "darkroom-android" name = "darkroom-android"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"dr-plat", "dr-plat",
@@ -1234,7 +1278,7 @@ dependencies = [
[[package]] [[package]]
name = "darkroom-desktop" name = "darkroom-desktop"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-plat", "dr-plat",
@@ -1356,6 +1400,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2 0.6.2",
"libc",
"objc2 0.6.4", "objc2 0.6.4",
] ]
@@ -1408,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]] [[package]]
name = "dr-bench" name = "dr-bench"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-catalog", "dr-catalog",
@@ -1425,7 +1471,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-catalog" name = "dr-catalog"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"dr-face", "dr-face",
"dr-plat", "dr-plat",
@@ -1440,7 +1486,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-decode" name = "dr-decode"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"env_logger", "env_logger",
@@ -1454,7 +1500,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-export" name = "dr-export"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-gpu", "dr-gpu",
@@ -1473,7 +1519,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-face" name = "dr-face"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1486,7 +1532,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-film" name = "dr-film"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -1495,7 +1541,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-gpu" name = "dr-gpu"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"dr-decode", "dr-decode",
@@ -1513,7 +1559,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-inference-engine" name = "dr-inference-engine"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"env_logger", "env_logger",
"libloading", "libloading",
@@ -1528,7 +1574,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ingest" name = "dr-ingest"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"dr-plat", "dr-plat",
"dr-types", "dr-types",
@@ -1540,7 +1586,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-lens" name = "dr-lens"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"lensfun", "lensfun",
"log", "log",
@@ -1548,7 +1594,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pano" name = "dr-pano"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-inference-engine", "dr-inference-engine",
@@ -1562,7 +1608,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pipeline" name = "dr-pipeline"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -1571,7 +1617,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-plat" name = "dr-plat"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"android-native-keyring-store", "android-native-keyring-store",
"dr-types", "dr-types",
@@ -1587,7 +1633,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-preset-xmp" name = "dr-preset-xmp"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"dr-pipeline", "dr-pipeline",
"log", "log",
@@ -1597,7 +1643,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-segment" name = "dr-segment"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1610,7 +1656,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync" name = "dr-sync"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-plat", "dr-plat",
@@ -1624,7 +1670,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-folder" name = "dr-sync-folder"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-sync", "dr-sync",
@@ -1636,7 +1682,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-nextcloud" name = "dr-sync-nextcloud"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-decode", "dr-decode",
@@ -1658,7 +1704,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-thumbs" name = "dr-thumbs"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"jpeg-encoder", "jpeg-encoder",
@@ -1670,7 +1716,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-types" name = "dr-types"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -1679,7 +1725,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ui" name = "dr-ui"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1710,7 +1756,9 @@ dependencies = [
"ndk-context", "ndk-context",
"png", "png",
"pollster", "pollster",
"raw-window-handle",
"reqwest", "reqwest",
"rfd",
"rusqlite", "rusqlite",
"serde_json", "serde_json",
"serde_norway", "serde_norway",
@@ -1725,7 +1773,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-xmp" name = "dr-xmp"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -5465,8 +5513,6 @@ dependencies = [
[[package]] [[package]]
name = "rawler" name = "rawler"
version = "0.7.2" version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04f4cc35c23969a4a834e0b117c7da41ace812eb9053b5effc3fc5c77d114677"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"bitstream-io", "bitstream-io",
@@ -5668,6 +5714,30 @@ dependencies = [
"zune-jpeg 0.5.15", "zune-jpeg 0.5.15",
] ]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"ashpd",
"block2 0.6.2",
"dispatch2",
"js-sys",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "rgb" name = "rgb"
version = "0.8.53" version = "0.8.53"
@@ -6286,6 +6356,7 @@ dependencies = [
"num-traits", "num-traits",
"once_cell", "once_cell",
"pin-weak", "pin-weak",
"raw-window-handle",
"slint-macros", "slint-macros",
"unicode-segmentation", "unicode-segmentation",
"vtable", "vtable",
@@ -7036,12 +7107,14 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]] [[package]]
name = "traceability" name = "traceability"
version = "0.16.0" version = "0.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"proc-macro2",
"pulldown-cmark", "pulldown-cmark",
"serde", "serde",
"serde_json", "serde_json",
"syn 2.0.119",
] ]
[[package]] [[package]]
@@ -7447,8 +7520,15 @@ dependencies = [
"idna", "idna",
"percent-encoding", "percent-encoding",
"serde", "serde",
"serde_derive",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "usvg" name = "usvg"
version = "0.47.0" version = "0.47.0"
@@ -8169,6 +8249,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -8217,13 +8306,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]] [[package]]
name = "windows-threading" name = "windows-threading"
version = "0.2.1" version = "0.2.1"
@@ -8251,6 +8357,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -8269,6 +8381,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
@@ -8287,12 +8405,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
@@ -8311,6 +8441,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
@@ -8329,6 +8465,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
@@ -8347,6 +8489,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -8365,6 +8513,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winit" name = "winit"
version = "0.30.13" version = "0.30.13"
@@ -8830,6 +8984,7 @@ dependencies = [
"endi", "endi",
"enumflags2", "enumflags2",
"serde", "serde",
"url",
"winnow 1.0.4", "winnow 1.0.4",
"zvariant_derive", "zvariant_derive",
"zvariant_utils", "zvariant_utils",
+14 -6
View File
@@ -32,7 +32,7 @@ members = [
exclude = ["third_party"] exclude = ["third_party"]
[workspace.package] [workspace.package]
version = "0.16.0" version = "0.19.1"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
@@ -134,6 +134,12 @@ serde_json = "1"
# Slint's Markdown parser, so this adds a dependency edge and no crate; only # Slint's Markdown parser, so this adds a dependency edge and no crate; only
# the HTML writer is needed, not the command-line front end. # the HTML writer is needed, not the command-line front end.
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
# The verdict-writer check (tools/traceability, FR-CULL-13) reads Rust as Rust:
# a text scan cannot tell a call from a comment, a test module from shipped
# code, or which callback closure a call sits in. Both already in the tree as
# every proc macro's parser; `span-locations` gives a problem its line.
syn = { version = "2", default-features = false, features = ["full", "parsing", "visit", "printing"] }
proc-macro2 = { version = "1", default-features = false, features = ["span-locations"] }
base64 = "0.23" base64 = "0.23"
# Display-server clients, for FR-DSP-8's per-display profile acquisition. # Display-server clients, for FR-DSP-8's per-display profile acquisition.
@@ -270,11 +276,13 @@ opt-level = 0
lto = "thin" lto = "thin"
codegen-units = 1 codegen-units = 1
# Two upstream crates carry a local patch so that the Android build can draw # Three upstream crates carry a local patch: wgpu-hal and Slint's Skia
# with wgpu on a rotated display (technical-debt.md TD-1). Both are exact # renderer so that the Android build can draw with wgpu on a rotated display
# copies of the version the lockfile already resolves, plus that patch; # (technical-debt.md TD-1), and rawler so that a linear DNG wider than 16 700
# third_party/README.md says what was changed and how to carry it forward # pixels decodes. Each is an exact copy of the version the lockfile already
# when Slint or wgpu moves. # resolves, plus its patch; third_party/README.md says what was changed and
# how to carry it forward when Slint, wgpu or rawler moves.
[patch.crates-io] [patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" } wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" } i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
rawler = { path = "third_party/rawler-0.7.2" }
+49 -31
View File
@@ -15,29 +15,44 @@ is still missing.
**A library.** Point it at a folder — on this machine, on a network mount, **A library.** Point it at a folder — on this machine, on a network mount,
or one a Nextcloud client keeps in virtual-files mode, where a placeholder or one a Nextcloud client keeps in virtual-files mode, where a placeholder
is treated as the photograph rather than as a one-byte file — or at a is treated as the photograph rather than as a one-byte file — or at a
Nextcloud account directly. The grid is virtualised, ordered by capture Nextcloud account directly; a photograph that is only on the server opens on
time with a timeline beside it, and filtered by rating, flag, colour label, its thumbnail with the download's progress over it. The grid is virtualised,
person and whether the file is here. Ratings, colour labels, keywords, ordered by capture time with a timeline beside it, and filtered by rating,
collections and a trash that survives a crash mid-operation. Card ingest. flag, colour label, person and whether the file is here. Ratings, colour
Bursts fold. The same RAW catalogued twice — a dated folder and a backup labels, keywords, collections and a trash that survives a crash
beside it — is found, proved the same, and folded onto one copy with the mid-operation. Card ingest. Bursts fold. The same RAW catalogued twice — a
spares in the trash. Face detection and identity, with the index syncing dated folder and a backup beside it — is found, proved the same, and folded
between devices. onto one copy with the spares in the trash. Face detection and identity,
with the index syncing between devices.
**Developing.** Eighteen declared operations fused into one compute **Developing.** Nineteen declared operations, those that read one pixel
dispatch, plus the neighbourhood work that cannot be: clarity, texture, fused into a generated shader rather than run a pass each, plus the
capture sharpening, noise reduction, lens correction, spectral film neighbourhood work that cannot be: clarity, texture, dehaze, capture
simulation. Crop, straighten and correct converging verticals, spot repair, sharpening, noise reduction, lens correction. Every edit works on the scene
and local adjustments over masks the model draws — click a subject or a as the camera recorded it — linear, highlights beyond white included — and
category, then paint, subtract a gradient or keep only where two selections one `Tone Mapping` step, last, after sharpening and noise reduction, fits it
agree, grow or shrink the edge. Focus peaking and a raw histogram for judging to the screen, with a contrast and a white point of its own; a spectral film
what is recoverable. Named presets; XMP sidecars other editors read. stock takes its place when one is chosen. Crop, straighten and correct
converging verticals, spot repair, and local adjustments over masks the
model draws — click a subject or a category, then paint, subtract a gradient
or keep only where two selections agree, grow or shrink the edge. A mask's
sliders add to the photograph's, the film's among them, so a sky can be
burned in on the print as a darkroom printer would. Hot and dead photosites
are mended before the demosaic, with nothing to set. Focus peaking and a raw
histogram for judging what is recoverable. Presets, a click away in a menu
at the foot of the tool rail, with a collection shipped in the application —
everyday corrections, and a look for each measured colour, cinema and
black-and-white stock — and Lightroom presets imported as looks that leave a
photograph's own corrections alone. XMP sidecars other editors read. A
linear DNG larger than one GPU texture — a stitched panorama twenty thousand
pixels wide — opens, develops and exports at full size.
[![Segmenting an urban scene and choosing the sky as a mask](docs/manual/media/local-segment.png)](docs/manual/README.md#local-adjustments) [![Segmenting an urban scene and choosing the sky as a mask](docs/manual/media/local-segment.png)](docs/manual/README.md#local-adjustments)
**Panoramas.** Select the frames, align, choose a projection, fill the **Panoramas.** Select the frames, align, untick any frame to leave it out
ragged border rather than crop it, and the composite lands beside its and the rest re-align at once, choose a projection, fill the ragged border
sources as a DNG, with a sidecar recording what it was merged from. rather than crop it, and the composite lands beside its sources as a DNG,
with a sidecar recording what it was merged from.
[![Twelve hand-held frames aligned on a cylinder](docs/manual/media/panorama-aligned.png)](docs/manual/README.md#merging-a-panorama) [![Twelve hand-held frames aligned on a cylinder](docs/manual/media/panorama-aligned.png)](docs/manual/README.md#merging-a-panorama)
@@ -49,8 +64,10 @@ binds it, and links them to the sections of the manual that show them — the
manual ships with the application and opens offline. manual ships with the application and opens offline.
**Export.** JPEG, PNG, AVIF, JPEG XL, 8- and 16-bit TIFF, with resize, output **Export.** JPEG, PNG, AVIF, JPEG XL, 8- and 16-bit TIFF, with resize, output
sharpening, a naming template and a colour space — to a folder here or back sharpening, a naming template and a colour space — into albums: named export
into the library. folders on this machine or on the server, never inside the library, which
remember the photograph behind each file and sync between devices as
collections do.
**On both platforms.** The same core runs on a desktop and a 12-inch **On both platforms.** The same core runs on a desktop and a 12-inch
tablet; the interface is one layout, tuned for a wide viewport with touch tablet; the interface is one layout, tuned for a wide viewport with touch
@@ -64,7 +81,7 @@ texture directly — no readback between the GPU and the screen.
| Arch Linux | [`packaging/PKGBUILD`](packaging/PKGBUILD) — `makepkg -si` | Built from every release | | Arch Linux | [`packaging/PKGBUILD`](packaging/PKGBUILD) — `makepkg -si` | Built from every release |
| Android | The APK from each CI run, or `./docker/android/package.sh --install` | Runs on a tablet; F-Droid not yet submitted | | Android | The APK from each CI run, or `./docker/android/package.sh --install` | Runs on a tablet; F-Droid not yet submitted |
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned | | Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned |
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; choosing a library does not yet work in the sandbox | | Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; folders are chosen through the portal, but no Flatpak has been built to prove it |
Or build it. Git LFS is required for the model weights, and the toolchain Or build it. Git LFS is required for the model weights, and the toolchain
pins itself to 1.92.0: pins itself to 1.92.0:
@@ -87,18 +104,19 @@ controls, its place in the chain and its tests.
## Where it stands ## Where it stands
**0.16.0**, twenty-four tagged releases in. 191 numbered requirements in **0.19.1**, thirty tagged releases in. 193 numbered requirements in
scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md); scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
the rest are written down rather than merely absent. the rest are written down rather than merely absent.
**Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and **Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
survey culling, AI denoise, tiled rendering, HDR merge and survey culling, AI denoise, tiled rendering beyond the export of an oversized
focus stacking, importing a Lightroom or darktable catalog, translations DNG, HDR merge and focus stacking, importing a Lightroom or darktable catalog,
beyond the launch screen, most of the Android platform integration beyond translations beyond the launch screen, most of the Android platform
running, and the Flatpak's library chooser. The performance targets are half integration beyond running, and a Flatpak actually built and run in its
verified: the per-commit benchmark suite §8 requires exists for everything sandbox. The performance targets are half verified: the per-commit benchmark
that does not need a frame — the catalog, the scan, the thumbnails — and suite §8 requires exists for everything that does not need a frame — the
not yet for the render path, so a regression there fails nothing. catalog, the scan, the thumbnails — and not yet for the render path, so a
regression there fails nothing.
[outstanding.md](docs/dev/outstanding.md) is the list, with the reasoning for [outstanding.md](docs/dev/outstanding.md) is the list, with the reasoning for
each. each.
@@ -158,6 +158,18 @@
android:theme="@style/ManualTheme" android:theme="@style/ManualTheme"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" /> android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
<!-- FR-EXP-10: the system's folder picker, for an album's folder on
this device. NativeActivity's onActivityResult is not ours, so
this activity exists only to ask and hand the answer back (see
FolderPicker.java). Translucent and without a title so nothing
of it shows but the system chooser; not exported, and started by
class name from dr_ui::saf. -->
<activity
android:name="paris.tourolle.darkroom.FolderPicker"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs <!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
between apps since API 24 — handing one out raises between apps since API 24 — handing one out raises
FileUriExposedException in *this* process — so an exported JPEG FileUriExposedException in *this* process — so an exported JPEG
@@ -0,0 +1,117 @@
package paris.tourolle.darkroom;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
/**
* The system's folder picker, for an album's folder on this device (FR-EXP-10).
*
* <h2>Why an activity of its own</h2>
*
* <p>{@code ACTION_OPEN_DOCUMENT_TREE} answers through
* {@code onActivityResult}, and the main activity is {@code NativeActivity},
* whose result callback is not ours to override. So this one exists only to
* ask: it starts the picker, takes the answer, and finishes — no layout, a
* translucent theme, nothing on screen but the system's own chooser, which has
* its own "New folder".
*
* <p>The answer is left in a static for Rust to poll ({@link #poll}), rather
* than called back into native code: a callback would need a registered
* native method and a thread to deliver on, and a poll from the Slint timer
* that is already running is one static call.
*
* <h2>The grant</h2>
*
* <p>A tree URI is usable only while its permission is held, and a plain
* result grants it until the process dies. {@code takePersistableUriPermission}
* keeps it across restarts — an album's folder is chosen once and exported to
* for months.
*/
public final class FolderPicker extends Activity {
private static final String TAG = "DarkRoom";
private static final int REQUEST = 0x5AF;
/** The last answer: a tree URI, "" for a cancel, null while none has come. */
private static volatile String answer = null;
/**
* Start asking. Clears any answer left from before.
*
* <p>Takes a {@code Context} rather than an {@code Activity}, because what
* native code holds (ndk_context's handle) is the application context,
* and starting an activity from one that is not an activity needs
* {@code FLAG_ACTIVITY_NEW_TASK} — without it the call throws. The picker
* shares the app's task affinity, so it still opens over the app and Back
* still returns to it.
*/
public static void start(Context from) {
answer = null;
Intent intent = new Intent(from, FolderPicker.class);
if (!(from instanceof Activity)) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
from.startActivity(intent);
}
/**
* The answer, once: a tree URI, "" if the user backed out, or null while
* the picker is still open. Reading it clears it, so a second poll after a
* cancel does not see the cancel again.
*/
public static String poll() {
String a = answer;
if (a != null) {
answer = null;
}
return a;
}
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
// Recreated after a rotation with the picker already up: asking again
// would stack a second chooser over the first.
if (state != null) {
return;
}
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
pick.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
try {
startActivityForResult(pick, REQUEST);
} catch (ActivityNotFoundException e) {
Log.w(TAG, "no folder picker on this device", e);
answer = "";
finish();
}
}
@Override
protected void onActivityResult(int request, int result, Intent data) {
if (request != REQUEST) {
return;
}
Uri tree = (result == RESULT_OK && data != null) ? data.getData() : null;
if (tree == null) {
answer = "";
} else {
try {
getContentResolver().takePersistableUriPermission(tree,
Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
} catch (SecurityException e) {
// Still usable this session; said in the log so a folder that
// stops working after a restart has an explanation.
Log.w(TAG, "the folder grant could not be kept: " + tree, e);
}
answer = tree.toString();
}
finish();
}
}
@@ -0,0 +1,114 @@
package paris.tourolle.darkroom;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.DocumentsContract;
import android.util.Log;
import java.io.IOException;
import java.io.OutputStream;
/**
* Writing an export into a folder the user granted through
* {@link FolderPicker} — the Storage Access Framework, which is the only way
* this app reaches a folder on the device (FR-PLAT-AND-1).
*
* <p>A tree URI is not a path: a child is found by listing the folder and
* matching its display name, and created through the provider, which may
* rename it on a collision. So the name that was actually written is handed
* back, and the album records that one.
*
* <p>Two static calls, strings and a byte array in, a string out, for the
* reason {@link Intents} gives: every call here would be a signature typed as
* a string on the Rust side, and the fewer of those the better.
*/
public final class Saf {
private static final String TAG = "DarkRoom";
private Saf() {
}
/** Whether {@code name} already exists in the folder. False on any error. */
public static boolean exists(Context context, String tree, String name) {
try {
return find(context.getContentResolver(), Uri.parse(tree), name) != null;
} catch (RuntimeException e) {
Log.w(TAG, "checking " + name + " in " + tree, e);
return false;
}
}
/**
* Write {@code bytes} as {@code name} in the folder, replacing a file of
* that name when {@code replace} is set.
*
* @return the name the file has in the folder — the provider may have
* added " (1)" — or null on failure, with the reason in the log.
*/
public static String write(Context context, String tree, String name, String mime,
byte[] bytes, boolean replace) {
ContentResolver resolver = context.getContentResolver();
Uri treeUri = Uri.parse(tree);
try {
Uri target = replace ? find(resolver, treeUri, name) : null;
if (target == null) {
Uri folder = DocumentsContract.buildDocumentUriUsingTree(treeUri,
DocumentsContract.getTreeDocumentId(treeUri));
target = DocumentsContract.createDocument(resolver, folder, mime, name);
}
if (target == null) {
Log.w(TAG, "the folder refused to create " + name + " in " + tree);
return null;
}
// "wt": truncate. A replacement shorter than what it replaces
// must not keep the old file's tail.
try (OutputStream out = resolver.openOutputStream(target, "wt")) {
if (out == null) {
Log.w(TAG, "no stream for " + target);
return null;
}
out.write(bytes);
}
String written = displayName(resolver, target);
return written != null ? written : name;
} catch (IOException | RuntimeException e) {
Log.w(TAG, "writing " + name + " to " + tree, e);
return null;
}
}
/** The document for {@code name} directly in the tree's folder, or null. */
private static Uri find(ContentResolver resolver, Uri tree, String name) {
String folderId = DocumentsContract.getTreeDocumentId(tree);
Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, folderId);
String[] columns = {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
};
try (Cursor c = resolver.query(children, columns, null, null, null)) {
if (c == null) {
return null;
}
while (c.moveToNext()) {
if (name.equals(c.getString(1))) {
return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0));
}
}
}
return null;
}
private static String displayName(ContentResolver resolver, Uri document) {
String[] columns = {DocumentsContract.Document.COLUMN_DISPLAY_NAME};
try (Cursor c = resolver.query(document, columns, null, null, null)) {
if (c != null && c.moveToFirst()) {
return c.getString(0);
}
} catch (RuntimeException e) {
Log.w(TAG, "reading the name of " + document, e);
}
return null;
}
}
+3 -1
View File
@@ -300,7 +300,9 @@ fn install_bundled_models(app: slint::android::AndroidApp) {
// on the worker because `AAssetManager` is thread-safe by contract and // on the worker because `AAssetManager` is thread-safe by contract and
// reading the pointer takes only the app's read lock, which `poll_events` // reading the pointer takes only the app's read lock, which `poll_events`
// also only ever holds shared. // also only ever holds shared.
std::thread::spawn(move || unpack_bundled_models(&app)); dr_ui::executors::spawn(dr_ui::executors::Executor::Io, "models", move || {
unpack_bundled_models(&app)
});
} }
/// The copy itself, on the worker [`install_bundled_models`] starts. /// The copy itself, on the worker [`install_bundled_models`] starts.
+180 -3
View File
@@ -1,14 +1,28 @@
//! What the catalog's routine reads cost on a real library, off the GUI. //! What the catalog's routine reads cost on a real library, off the GUI.
//! //!
//! cargo run --release -p dr-catalog --example catalog_bench -- CATALOG.sqlite [FACES_DIR] //! cargo run --release -p dr-catalog --example catalog_bench -- CATALOG.sqlite [FACES_DIR] [--remote PEER.sqlite]
//! //!
//! Times `Catalog::open` — which every worker thread pays, including the //! Times `Catalog::open` — which every worker thread pays, including the
//! develop view's fetch of each original and each neighbour it prefetches — //! develop view's fetch of each original and each neighbour it prefetches —
//! and the backfill that runs inside it, step by step. Run it against a //! and the backfill that runs inside it, step by step. Run it against a
//! *copy* of a real catalog: opening migrates and backfills, which write. //! *copy* of a real catalog: opening migrates and backfills, which write.
//! //!
//! Then what the library screen reads on every keystroke and scroll: the
//! filter chips' counts, the keyword panel, and the grid's total. Two of
//! those live in `dr-ui` (`library::local_original_count` and the grid
//! count), whose `library` module is private; their SQL is spelled here as
//! it is spelled there, and has to be kept in step by hand.
//!
//! `--remote` also times a merge with another device's catalog — the
//! server snapshot — which is the pass where the two disagree: faces one
//! side found and the other did not, boxes that moved. The merge with a copy
//! of itself matches every face by its box and never reaches that work. The
//! first of its runs writes what the peer brought; the rest are the steady
//! state, so compare two builds from two fresh copies of one catalog.
//!
//! The figures are for reading side by side before and after a change; they //! The figures are for reading side by side before and after a change; they
//! are not a gate. Compare the `cpu` column when the machine is busy. //! are not a gate. Compare the `cpu` column when the machine is busy. The
//! answers are printed too, so two builds can be checked for agreeing.
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -16,7 +30,15 @@ use std::time::{Duration, Instant};
use dr_catalog::{keywords, rating, schema, Catalog}; use dr_catalog::{keywords, rating, schema, Catalog};
fn main() { fn main() {
let args: Vec<String> = std::env::args().skip(1).collect(); let mut args: Vec<String> = std::env::args().skip(1).collect();
let peer = args.iter().position(|a| a == "--remote").map(|at| {
let path = args.get(at + 1).map(PathBuf::from).unwrap_or_else(|| {
eprintln!("--remote needs a catalog");
std::process::exit(2);
});
args.drain(at..at + 2);
path
});
let Some(path) = args.first().map(PathBuf::from) else { let Some(path) = args.first().map(PathBuf::from) else {
eprintln!("usage: catalog_bench CATALOG.sqlite"); eprintln!("usage: catalog_bench CATALOG.sqlite");
std::process::exit(2); std::process::exit(2);
@@ -29,6 +51,49 @@ fn main() {
drop(Catalog::open(&path).unwrap()); drop(Catalog::open(&path).unwrap());
}); });
// One develop landing, in the two shapes the app has had. Five opens is
// what `fetch_original` and a `holds_original` per prefetched neighbour
// cost when each asked on a connection of its own; two is the fetch plus
// one connection the prefetch worker keeps for its batch's row checks.
// Five images from the library, against an empty cache: the question is
// asked the same way whatever the answer.
let images: Vec<dr_types::ImageId> = {
let c = Catalog::open(&path).unwrap();
let mut stmt = c
.connection()
.prepare("SELECT id FROM images ORDER BY id LIMIT 5 OFFSET 1000")
.unwrap();
let ids = stmt
.query_map([], |r| r.get::<_, i64>(0))
.unwrap()
.map(|id| dr_types::ImageId(id.unwrap() as u64))
.collect();
ids
};
let cache_dir = path.with_extension("bench-cache");
let budget = dr_catalog::Budget::default();
time("landing: 5 opens (fetch + 4 row checks)", 20, || {
let store = dr_catalog::Cache::open(&cache_dir, budget).unwrap();
let c = Catalog::open(&path).unwrap();
let _ = store.load(c.connection(), images[0], 0).unwrap();
for &image in &images[1..] {
let store = dr_catalog::Cache::open(&cache_dir, budget).unwrap();
let c = Catalog::open(&path).unwrap();
let _ = store.holds_original(c.connection(), image);
}
});
time("landing: 2 opens (fetch + held row checks)", 20, || {
let store = dr_catalog::Cache::open(&cache_dir, budget).unwrap();
let c = Catalog::open(&path).unwrap();
let _ = store.load(c.connection(), images[0], 0).unwrap();
let held = Catalog::open(&path).unwrap();
for &image in &images[1..] {
let store = dr_catalog::Cache::open(&cache_dir, budget).unwrap();
let _ = store.holds_original(held.connection(), image);
}
});
let _ = std::fs::remove_dir_all(&cache_dir);
let catalog = Catalog::open(&path).unwrap(); let catalog = Catalog::open(&path).unwrap();
let conn = catalog.connection(); let conn = catalog.connection();
time("schema::backfill (all steps)", 20, || { time("schema::backfill (all steps)", 20, || {
@@ -44,6 +109,8 @@ fn main() {
keywords::adopt_orphan_terms(conn).unwrap(); keywords::adopt_orphan_terms(conn).unwrap();
}); });
interactive(conn);
// A sync pass: the upload snapshot, then a merge of the catalog with a // A sync pass: the upload snapshot, then a merge of the catalog with a
// copy of itself — every row a match, which is the steady state. // copy of itself — every row a match, which is the steady state.
let scratch = path.with_extension("bench-snapshot"); let scratch = path.with_extension("bench-snapshot");
@@ -65,6 +132,19 @@ fn main() {
let _ = std::fs::remove_file(&scratch); let _ = std::fs::remove_file(&scratch);
let _ = std::fs::remove_file(&remote); let _ = std::fs::remove_file(&remote);
if let Some(peer) = &peer {
// A copy, so nothing the merge does to its input reaches the file
// the caller named.
std::fs::copy(peer, &remote).unwrap();
let mut first = None;
time("merge_remote_catalog (--remote)", 5, || {
let report = catalog.merge_remote_catalog(&remote).unwrap();
first.get_or_insert(report);
});
println!(" first pass: {first:?}");
let _ = std::fs::remove_file(&remote);
}
// The face half of a sync pass, against a copy of the face store: both // The face half of a sync pass, against a copy of the face store: both
// directions in the steady state, where nothing is new either way. // directions in the steady state, where nothing is new either way.
if let Some(faces) = args.get(1).map(PathBuf::from) { if let Some(faces) = args.get(1).map(PathBuf::from) {
@@ -84,6 +164,103 @@ fn main() {
} }
} }
/// What one click in the library reads: a rating or label keystroke
/// refreshes the chips, a selection change redraws the keyword panel, and
/// every scroll reload counts the grid.
fn interactive(conn: &rusqlite::Connection) {
println!(
" rating_histogram {:?}, label_histogram {:?}, local originals {}, grid {} / rated {}",
rating::rating_histogram(conn).unwrap(),
rating::label_histogram(conn).unwrap(),
local_original_count(conn),
grid_count(conn, ""),
grid_count(conn, RATED_AT_LEAST_ONE),
);
let words = keywords::list(conn).unwrap();
println!(
" keywords::list {} terms, digest {:016x}",
words.len(),
digest(&format!("{words:?}"))
);
// A selection the size of a grid window, from the start of the library.
let selection: Vec<dr_types::ImageId> = conn
.prepare("SELECT id FROM images ORDER BY id LIMIT 120")
.unwrap()
.query_map([], |r| Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64)))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
println!(
" keywords::for_images digest {:016x}",
digest(&format!(
"{:?}",
keywords::for_images(conn, &selection).unwrap()
))
);
time("rating::rating_histogram", 50, || {
rating::rating_histogram(conn).unwrap();
});
time("library::local_original_count", 50, || {
local_original_count(conn);
});
time("rating::label_histogram", 50, || {
rating::label_histogram(conn).unwrap();
});
time("keywords::list", 50, || {
keywords::list(conn).unwrap();
});
time("keywords::for_images (120)", 50, || {
keywords::for_images(conn, &selection).unwrap();
});
time("grid count", 50, || {
grid_count(conn, "");
});
time("grid count, rated >= 1", 50, || {
grid_count(conn, RATED_AT_LEAST_ONE);
});
}
/// `dr_ui::library::local_original_count`, spelled as it is there.
fn local_original_count(conn: &rusqlite::Connection) -> i64 {
conn.query_row(
"SELECT count(*) FROM images i
WHERE i.shadowed_by IS NULL AND i.trashed_at IS NULL
AND i.id IN (SELECT ic.image_id FROM image_cache ic
WHERE ic.tier_actual >= 2)",
[],
|r| r.get(0),
)
.unwrap()
}
/// `RatingFilter::sql` for one star and up.
const RATED_AT_LEAST_ONE: &str = " AND coalesce((SELECT dv.rating FROM versions dv
WHERE dv.image_id = i.id AND dv.is_default = 1
LIMIT 1), 0) >= 1";
/// `dr_ui::library::total_images_filtered`, spelled as it is there.
fn grid_count(conn: &rusqlite::Connection, rated: &str) -> i64 {
let visible = "i.shadowed_by IS NULL AND i.trashed_at IS NULL";
let hidden = dr_catalog::bursts::collapsed_away_frames("i");
conn.query_row(
&format!(
"SELECT (SELECT count(*) FROM images i WHERE {visible}{rated})
- (SELECT count(*) FROM {hidden} AND {visible}{rated})"
),
[],
|r| r.get(0),
)
.unwrap()
}
/// FNV-1a, to print a long answer as something two runs can compare.
fn digest(s: &str) -> u64 {
s.bytes().fold(0xcbf29ce484222325, |h, b| {
(h ^ u64::from(b)).wrapping_mul(0x100000001b3)
})
}
/// Run `f` a few times and print the best wall-clock, the median, and the /// Run `f` a few times and print the best wall-clock, the median, and the
/// best CPU time — the figure to compare across runs on a busy machine. /// best CPU time — the figure to compare across runs on a busy machine.
fn time(label: &str, runs: usize, mut f: impl FnMut()) { fn time(label: &str, runs: usize, mut f: impl FnMut()) {
+141
View File
@@ -0,0 +1,141 @@
//! Run the people and face deduplication (#78) on a copy of a real catalog.
//!
//! cargo run --release -p dr-catalog --example dedup_people -- COPY.sqlite [--peer PEER_COPY.sqlite]
//!
//! It writes: run it against a *copy* (`sqlite3 catalog.sqlite ".backup
//! copy.sqlite"`), never the library's own file. Prints the live people and
//! faces before and after, what the first run merged and kept apart, and
//! how long the first and a second run took -- the second is the cost the
//! job adds to every sync once a catalog is clean.
//!
//! `--peer` then plays a sync round trip with another device's catalog (a
//! copy of the server snapshot, which it also writes): the peer merges this
//! one as the previous release would, with no job after it, then this one
//! merges the peer back through `sync::merge_remote`, twice. The named
//! people each side lists are printed after each step; they should agree.
use std::path::PathBuf;
use std::time::Instant;
use dr_catalog::{dedup_people, merge, schema, sync};
use rusqlite::Connection;
fn open(path: &std::path::Path) -> Connection {
let conn = Connection::open(path).expect("open the catalog copy");
schema::configure(&conn).expect("configure");
schema::migrate(&conn).expect("migrate");
conn
}
/// The named people a device lists, as `name (uuid prefix)`, sorted.
fn named(conn: &Connection) -> Vec<String> {
let mut v: Vec<String> = conn
.prepare(
"SELECT name, substr(uuid, 1, 8) FROM people
WHERE merged_into IS NULL AND trim(name) <> ''",
)
.unwrap()
.query_map([], |r| {
Ok(format!(
"{} ({})",
r.get::<_, String>(0)?,
r.get::<_, String>(1)?
))
})
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
v.sort();
v
}
fn main() {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let peer = args.iter().position(|a| a == "--peer").map(|at| {
let p = PathBuf::from(&args[at + 1]);
args.drain(at..at + 2);
p
});
let Some(path) = args.first().map(PathBuf::from) else {
eprintln!("usage: dedup_people COPY.sqlite [--peer PEER_COPY.sqlite]");
std::process::exit(2);
};
let conn = open(&path);
let counts = |label: &str| {
let q = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
println!(
"{label}: {} people listed ({} named), {} redirects, {} faces, {} confirmed",
q("SELECT COUNT(*) FROM people WHERE merged_into IS NULL"),
q("SELECT COUNT(*) FROM people WHERE merged_into IS NULL AND trim(name) <> ''"),
q("SELECT COUNT(*) FROM people WHERE merged_into IS NOT NULL"),
q("SELECT COUNT(*) FROM faces"),
q("SELECT COUNT(*) FROM face_person WHERE confirmed = 1"),
);
};
counts("before");
for pass in ["first", "second", "third"] {
let started = Instant::now();
let report = dedup_people::run(&conn).expect("dedup");
let took = started.elapsed();
println!("{pass} run: {took:?}, changed: {}", report.changed());
if pass == "first" {
println!(" merged: {:?}", report.merged);
for k in &report.kept_apart {
println!(
" kept apart: {:?} ({}) from {}: {:?}",
k.name, k.uuid, k.survivor, k.why
);
}
println!(
" redirects followed {}, cycles broken {}, faces fused {}, faces confirmed apart {}",
report.redirects_followed,
report.cycles_broken,
report.faces_fused,
report.faces_confirmed_apart
);
}
}
counts("after");
let Some(peer_path) = peer else { return };
let peer = open(&peer_path);
let show = |step: &str| {
let (ours, theirs) = (named(&conn), named(&peer));
println!(
"{step}: this device lists {} named, the peer {}; {}",
ours.len(),
theirs.len(),
if ours == theirs {
"the same".to_string()
} else {
format!("differ:\n here {ours:?}\n peer {theirs:?}")
}
);
};
show("before the round trip");
for round in 1..=2 {
peer.execute(
"ATTACH DATABASE ?1 AS remote_cat",
[path.to_string_lossy().as_ref()],
)
.unwrap();
let theirs = merge::merge_all(&peer).expect("the peer's merge");
peer.execute("DETACH DATABASE remote_cat", []).unwrap();
println!(
"round {round}: the peer took {} people updated, {} inserted",
theirs.people_updated, theirs.people_inserted
);
show(&format!("round {round}, after the peer's merge"));
let started = Instant::now();
let ours = sync::merge_remote(&conn, &peer_path).expect("our merge");
println!(
"round {round}: merge_remote with the job took {:?}; {} people updated, {} inserted",
started.elapsed(),
ours.people_updated,
ours.people_inserted
);
show(&format!("round {round}, after ours"));
}
}
+516
View File
@@ -0,0 +1,516 @@
//! TRACES: FR-EXP-10 | FR-EXP-6 | FR-CAT-7
//! Albums: named export folders, and which photographs went into each.
//!
//! An album is where finished pictures go — a folder of JPEGs somebody else
//! looks at — as opposed to a collection, which is a set of originals the
//! photographer works on. The folder holds only the exported files. What the
//! catalog adds is the link back: each export is recorded against the image
//! it was rendered from, so opening an album in the library shows the RAWs
//! behind its JPEGs, and re-exporting after an edit is one selection away.
//!
//! # Where the folder is, and why that is two tables
//!
//! An album's folder is either on the library's server or on this device.
//!
//! A **server folder** is one path on the account, the same from every device
//! signed in to it, so it lives on the album row and syncs with it.
//!
//! A **local folder** — a filesystem path on a desktop, a Storage Access
//! Framework tree on Android — means nothing on any other device. It lives in
//! `album_folders`, which the merge never reads and the upload snapshot drops
//! ([`crate::sync::snapshot_for_upload`]). An album made on the desktop with a
//! local folder therefore reaches the tablet as an album with no folder there
//! yet, which is true, and which the tablet can fix by choosing one.
//!
//! # Created on first use, not by a migration
//!
//! A new schema version makes every older build refuse this catalog's
//! snapshot at sync (`crate::sync::remote_is_mergeable`), so the tablet would
//! stop merging collections, keywords and people until it was updated — for
//! a feature it does not have. The tables are created by [`ensure_tables`]
//! instead, the way `dedup_probes` is; an older build that meets them ignores
//! them, and its merge keeps working.
//!
//! # Sync
//!
//! Albums merge by uuid and revision with tombstones, and their exports as a
//! set union keyed on the image's server file id — the rules
//! [`crate::merge`] applies to collections, for the same reasons.
use rusqlite::{Connection, OptionalExtension};
use dr_types::ImageId;
use crate::error::CatalogError;
/// Identifies an album within one catalog. Local, like every integer id here;
/// the uuid is what crosses devices.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AlbumId(pub u64);
/// Where an album's files go.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Place {
/// A folder on the library's server, relative to the account root, with no
/// leading slash. The same on every device.
Server(String),
/// A folder on this device: a filesystem path, or on Android a SAF tree
/// URI. Never synced.
Local(String),
}
/// One album, as the sidebar and the export sheet show it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Album {
pub id: AlbumId,
pub uuid: String,
pub name: String,
/// Where exports go from this device, or `None` for an album whose folder
/// is local to another device and has not been chosen here.
pub place: Option<Place>,
/// Distinct photographs exported into it — what the grid shows when the
/// album is opened.
pub sources: usize,
}
/// Create the album tables if this catalog does not have them yet.
///
/// Cheap when they exist: `IF NOT EXISTS` is answered from the schema, and
/// every function below calls this first so no caller has to remember to.
pub fn ensure_tables(conn: &Connection) -> Result<(), CatalogError> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY,
-- The merge identity; the integer id is local.
uuid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
-- A folder on the server, relative to the account root. NULL for
-- an album whose folder is local to some device.
server_path TEXT,
created INTEGER NOT NULL,
revision INTEGER NOT NULL DEFAULT 1,
modified INTEGER NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);
-- One row per file written into an album. Keyed on the file, not the
-- image: a photograph exported twice — two crops, or once before an
-- edit and once after — is two files in the folder and two rows here.
CREATE TABLE IF NOT EXISTS album_exports (
album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
exported_at INTEGER NOT NULL,
PRIMARY KEY (album_id, file_name)
);
CREATE INDEX IF NOT EXISTS album_exports_image ON album_exports(image_id);
-- This device's folder for an album. Never merged, never uploaded.
CREATE TABLE IF NOT EXISTS album_folders (
album_id INTEGER PRIMARY KEY REFERENCES albums(id) ON DELETE CASCADE,
folder TEXT NOT NULL
);",
)?;
Ok(())
}
/// Make an album.
///
/// The name is trimmed and must not be empty; two albums may share one, as
/// two collections may, because the uuid is the identity and refusing a
/// duplicate name here would refuse it on one device and not another.
pub fn create(conn: &Connection, name: &str, place: &Place) -> Result<AlbumId, CatalogError> {
ensure_tables(conn)?;
let name = name.trim();
if name.is_empty() {
return Err(CatalogError::EmptyName);
}
let now = now_secs();
let tx = conn.unchecked_transaction()?;
tx.execute(
"INSERT INTO albums(uuid, name, server_path, created, revision, modified)
VALUES (?1, ?2, ?3, ?4, 1, ?4)",
rusqlite::params![
crate::collections::new_uuid(),
name,
server_path(place),
now
],
)?;
let id = AlbumId(tx.last_insert_rowid() as u64);
if let Place::Local(folder) = place {
tx.execute(
"INSERT INTO album_folders(album_id, folder) VALUES (?1, ?2)",
rusqlite::params![id.0 as i64, folder],
)?;
}
tx.commit()?;
Ok(id)
}
/// Rename an album. The folder keeps its name: the album is what the
/// photographer calls it, the folder is what is already out there.
pub fn rename(conn: &Connection, id: AlbumId, name: &str) -> Result<(), CatalogError> {
ensure_tables(conn)?;
let name = name.trim();
if name.is_empty() {
return Err(CatalogError::EmptyName);
}
let n = conn.execute(
"UPDATE albums SET name = ?2, revision = revision + 1, modified = ?3
WHERE id = ?1 AND deleted = 0",
rusqlite::params![id.0 as i64, name, now_secs()],
)?;
if n == 0 {
return Err(CatalogError::NoSuchAlbum(id.0));
}
Ok(())
}
/// Point an album at a different folder, from this device.
///
/// A server folder replaces the synced path, and bumps the revision so the
/// move reaches every device. A local folder is recorded for this device
/// only; it also clears a server path, because an album goes to one place and
/// the photographer has just said which.
pub fn set_place(conn: &Connection, id: AlbumId, place: &Place) -> Result<(), CatalogError> {
ensure_tables(conn)?;
let tx = conn.unchecked_transaction()?;
let n = tx.execute(
"UPDATE albums SET server_path = ?2, revision = revision + 1, modified = ?3
WHERE id = ?1 AND deleted = 0",
rusqlite::params![id.0 as i64, server_path(place), now_secs()],
)?;
if n == 0 {
return Err(CatalogError::NoSuchAlbum(id.0));
}
match place {
Place::Local(folder) => tx.execute(
"INSERT INTO album_folders(album_id, folder) VALUES (?1, ?2)
ON CONFLICT(album_id) DO UPDATE SET folder = excluded.folder",
rusqlite::params![id.0 as i64, folder],
)?,
Place::Server(_) => tx.execute(
"DELETE FROM album_folders WHERE album_id = ?1",
[id.0 as i64],
)?,
};
tx.commit()?;
Ok(())
}
/// Delete an album, leaving a tombstone. The files in its folder are not
/// touched: they are finished work somebody may already have been sent a
/// link to, and the album was only ever this catalog's note of them.
pub fn delete(conn: &Connection, id: AlbumId) -> Result<(), CatalogError> {
ensure_tables(conn)?;
let tx = conn.unchecked_transaction()?;
let n = tx.execute(
"UPDATE albums SET deleted = 1, revision = revision + 1, modified = ?2
WHERE id = ?1 AND deleted = 0",
rusqlite::params![id.0 as i64, now_secs()],
)?;
if n == 0 {
return Err(CatalogError::NoSuchAlbum(id.0));
}
tx.execute(
"DELETE FROM album_exports WHERE album_id = ?1",
[id.0 as i64],
)?;
tx.execute(
"DELETE FROM album_folders WHERE album_id = ?1",
[id.0 as i64],
)?;
tx.commit()?;
Ok(())
}
/// Every live album, by name, with how many photographs each holds.
///
/// One statement: the counts are aggregated from `album_exports` first and
/// joined to the (few) albums, not counted per row.
pub fn list(conn: &Connection) -> Result<Vec<Album>, CatalogError> {
ensure_tables(conn)?;
let mut stmt = conn.prepare(
"SELECT a.id, a.uuid, a.name, a.server_path, f.folder, coalesce(e.n, 0)
FROM albums a
LEFT JOIN album_folders f ON f.album_id = a.id
LEFT JOIN (SELECT album_id, count(DISTINCT image_id) AS n
FROM album_exports GROUP BY album_id) e
ON e.album_id = a.id
WHERE a.deleted = 0
ORDER BY a.name COLLATE NOCASE, a.id",
)?;
let rows = stmt
.query_map([], album_from_row)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// One album, or `None` if it is gone.
pub fn get(conn: &Connection, id: AlbumId) -> Result<Option<Album>, CatalogError> {
ensure_tables(conn)?;
Ok(conn
.query_row(
"SELECT a.id, a.uuid, a.name, a.server_path, f.folder,
(SELECT count(DISTINCT image_id) FROM album_exports WHERE album_id = a.id)
FROM albums a
LEFT JOIN album_folders f ON f.album_id = a.id
WHERE a.id = ?1 AND a.deleted = 0",
[id.0 as i64],
album_from_row,
)
.optional()?)
}
/// The album with this uuid, if this catalog holds it live.
pub fn id_for_uuid(conn: &Connection, uuid: &str) -> Result<Option<AlbumId>, CatalogError> {
ensure_tables(conn)?;
Ok(conn
.query_row(
"SELECT id FROM albums WHERE uuid = ?1 AND deleted = 0",
[uuid],
|r| r.get::<_, i64>(0),
)
.optional()?
.map(|id| AlbumId(id as u64)))
}
/// Record the files one export wrote into an album, and which image each
/// came from. One transaction for the batch, however many files it placed.
///
/// A file name already recorded is re-pointed at the image that wrote it
/// last: an export that overwrote `IMG_0001.jpg` replaced the picture in the
/// folder, and the link must say what is there now.
pub fn record_exports(
conn: &Connection,
id: AlbumId,
files: &[(ImageId, String)],
) -> Result<(), CatalogError> {
ensure_tables(conn)?;
if files.is_empty() {
return Ok(());
}
let tx = conn.unchecked_transaction()?;
let now = now_secs();
{
let mut insert = tx.prepare(
"INSERT INTO album_exports(album_id, file_name, image_id, exported_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(album_id, file_name) DO UPDATE SET
image_id = excluded.image_id, exported_at = excluded.exported_at",
)?;
for (image, name) in files {
insert.execute(rusqlite::params![id.0 as i64, name, image.0 as i64, now])?;
}
}
tx.commit()?;
Ok(())
}
/// The photographs behind an album's files, most recently exported first —
/// what the grid shows when the album is opened.
pub fn sources(conn: &Connection, id: AlbumId) -> Result<Vec<ImageId>, CatalogError> {
ensure_tables(conn)?;
let mut stmt = conn.prepare(
"SELECT image_id FROM album_exports
WHERE album_id = ?1
GROUP BY image_id
ORDER BY max(exported_at) DESC, image_id",
)?;
let rows = stmt
.query_map([id.0 as i64], |r| r.get::<_, i64>(0))?
.map(|r| r.map(|i| ImageId(i as u64)))
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// The names of the files an image left in an album — the "which JPEG is
/// this" half of the link.
pub fn files_of(
conn: &Connection,
id: AlbumId,
image: ImageId,
) -> Result<Vec<String>, CatalogError> {
ensure_tables(conn)?;
let mut stmt = conn.prepare(
"SELECT file_name FROM album_exports
WHERE album_id = ?1 AND image_id = ?2
ORDER BY exported_at DESC, file_name",
)?;
let rows = stmt
.query_map(rusqlite::params![id.0 as i64, image.0 as i64], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
fn album_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Album> {
let server: Option<String> = r.get(3)?;
let local: Option<String> = r.get(4)?;
Ok(Album {
id: AlbumId(r.get::<_, i64>(0)? as u64),
uuid: r.get(1)?,
name: r.get(2)?,
// A server path wins: `set_place` clears the local folder when it
// sets one, so both being present means a merge brought a server
// path in over a local choice — and the newer revision decided that.
place: server.map(Place::Server).or(local.map(Place::Local)),
sources: r.get::<_, i64>(5)? as usize,
})
}
fn server_path(place: &Place) -> Option<&str> {
match place {
Place::Server(p) => Some(p.trim_matches('/')),
Place::Local(_) => None,
}
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
/// A catalog, and the connection to it. The `Catalog` has to outlive the
/// connection it hands out, so tests hold both.
fn catalog() -> crate::Catalog {
let cat = crate::Catalog::in_memory().unwrap();
cat.connection()
.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
cat
}
fn image(conn: &Connection, path: &str) -> ImageId {
conn.execute(
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
[path],
)
.unwrap();
ImageId(conn.last_insert_rowid() as u64)
}
#[test]
fn an_album_lists_with_its_place_and_no_photographs() {
let cat = catalog();
let conn = cat.connection();
let web = create(conn, " Web ", &Place::Server("Shared/Web/".into())).unwrap();
let print = create(conn, "Print", &Place::Local("/mnt/print".into())).unwrap();
let all = list(conn).unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].id, print, "sorted by name");
assert_eq!(all[0].place, Some(Place::Local("/mnt/print".into())));
assert_eq!(all[1].id, web);
assert_eq!(all[1].name, "Web", "trimmed");
assert_eq!(all[1].place, Some(Place::Server("Shared/Web".into())));
assert_eq!(all[1].sources, 0);
}
#[test]
fn an_empty_name_is_refused() {
let cat = catalog();
let conn = cat.connection();
assert!(matches!(
create(conn, " ", &Place::Local("/x".into())),
Err(CatalogError::EmptyName)
));
}
#[test]
fn exports_link_files_back_to_their_images() {
let cat = catalog();
let conn = cat.connection();
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
let a = image(conn, "a.cr3");
let b = image(conn, "b.cr3");
record_exports(
conn,
album,
&[
(a, "a.jpg".into()),
(a, "a (1).jpg".into()),
(b, "b.jpg".into()),
],
)
.unwrap();
let got = get(conn, album).unwrap().unwrap();
assert_eq!(got.sources, 2, "two photographs, three files");
let mut s = sources(conn, album).unwrap();
s.sort();
assert_eq!(s, vec![a, b]);
assert_eq!(files_of(conn, album, a).unwrap().len(), 2);
}
#[test]
fn an_overwritten_file_points_at_what_wrote_it_last() {
let cat = catalog();
let conn = cat.connection();
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
let a = image(conn, "a.cr3");
let b = image(conn, "b.cr3");
record_exports(conn, album, &[(a, "x.jpg".into())]).unwrap();
record_exports(conn, album, &[(b, "x.jpg".into())]).unwrap();
assert_eq!(sources(conn, album).unwrap(), vec![b]);
}
#[test]
fn moving_to_the_server_forgets_the_local_folder() {
let cat = catalog();
let conn = cat.connection();
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
set_place(conn, album, &Place::Server("Web".into())).unwrap();
assert_eq!(
get(conn, album).unwrap().unwrap().place,
Some(Place::Server("Web".into()))
);
set_place(conn, album, &Place::Local("/again".into())).unwrap();
assert_eq!(
get(conn, album).unwrap().unwrap().place,
Some(Place::Local("/again".into()))
);
}
#[test]
fn a_deleted_album_is_gone_and_its_uuid_no_longer_resolves() {
let cat = catalog();
let conn = cat.connection();
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
let uuid = get(conn, album).unwrap().unwrap().uuid;
let a = image(conn, "a.cr3");
record_exports(conn, album, &[(a, "a.jpg".into())]).unwrap();
delete(conn, album).unwrap();
assert!(list(conn).unwrap().is_empty());
assert_eq!(id_for_uuid(conn, &uuid).unwrap(), None);
assert!(matches!(
rename(conn, album, "Again"),
Err(CatalogError::NoSuchAlbum(_))
));
}
#[test]
fn a_rename_bumps_the_revision_the_merge_compares() {
let cat = catalog();
let conn = cat.connection();
let album = create(conn, "Web", &Place::Local("/out".into())).unwrap();
rename(conn, album, "Website").unwrap();
let rev: i64 = conn
.query_row(
"SELECT revision FROM albums WHERE id = ?1",
[album.0 as i64],
|r| r.get(0),
)
.unwrap();
assert_eq!(rev, 2);
}
}
+417
View File
@@ -0,0 +1,417 @@
//! TRACES: NFR-P9
//! Which catalog files this process has already backfilled, and as of what.
//!
//! # Why this exists
//!
//! [`crate::schema::backfill`] used to run inside every [`crate::Catalog::open`],
//! and every worker thread opens its own connection. Landing on a photograph
//! in develop opened the catalog five times — the fetch of the original and a
//! cache check per prefetched neighbour — and each open paid the whole
//! backfill: an anti-join of every image against its versions, a pass over
//! every default version's uuid, the unpaired JPEGs and the keyword
//! vocabulary. On the reference library that was ~12 ms an open and ~60 ms of
//! CPU a landing, spent confirming that nothing had changed since the open
//! before.
//!
//! # What makes skipping it safe
//!
//! Everything the backfill repairs is a row some write *added*: an image
//! inserted by a scan or an import has no default version and may be the RAW
//! beside an unpaired JPEG; a version merged or restored from an older build
//! may carry a minted uuid; a keyword assignment merged from a remote may name
//! a word with no term. So the question "is any work owed?" is answered by
//! whether those tables have gained rows since the last backfill, and that is
//! a read of each table's last row — the last page of its b-tree — rather than
//! a scan.
//!
//! # Why the last row, and not only its id
//!
//! None of these tables is `AUTOINCREMENT`, so SQLite hands out the largest
//! rowid plus one, and an id freed by deleting the newest row is handed out
//! again. That is an ordinary sequence, not a contrived one: emptying the
//! trash of the newest photograph and then scanning a new one, or a local
//! folder's walk removing a renamed file's row and inserting the new name in
//! the same pass. `max(id)` does not move, and neither does `count(*)`. And
//! the row that took the id is exactly one that needs the backfill, because
//! neither scan creates default versions — `persist` and the walk insert the
//! image and leave the version, the pairing and the keyword terms to the next
//! open. Skipped, it would go without them until the app restarted: a rating
//! or a keyword with nowhere to land, a JPEG beside its RAW shown twice.
//!
//! So the stamp carries the last row's content as well as its id: the newest
//! image's path, when it was added, and **whether it has a version**; the
//! newest version's image; the newest assignment's word and version. Whether
//! the newest image has a version is the part that cannot be fooled: once the
//! backfill has run, every image has one, and a row that has just taken a
//! freed id has none, so the two stamps differ whatever the path and the time
//! say. The others make the newest version or assignment a different row
//! whenever a different one took its id; one that is the same content at the
//! same id is the same row as far as the backfill is concerned.
//!
//! The [`Stamp`] is those, the schema version, and the file's identity.
//! An open whose stamp matches the one recorded at the last backfill of the
//! same path skips it; anything else runs it. That covers the cases that must
//! run it:
//!
//! - **The first open in a process.** Nothing is recorded yet.
//! - **A migration.** `user_version` is in the stamp, and [`crate::Catalog::open`]
//! also runs the backfill unconditionally whenever `migrate` moved the
//! schema, because that is what the backfill was written for.
//! - **A pulled catalog.** The merge inserts assignments, which moves the
//! stamp; and [`crate::sync::merge_remote`] [`forget`]s the path as well, so
//! the next open backfills even when every incoming row collided.
//! - **A file replaced underneath the path** — a restore from backup, a
//! rebuild, a catalog copied in. On unix the device and inode are in the
//! stamp, and a replacement is a new inode; [`crate::recovery::set_aside`],
//! the first step of both a restore and a rebuild, forgets the path too.
//! - **Another process writing.** The stamp is read from the file, not from
//! anything this process did, so a scan in a second instance moves it just
//! the same.
//!
//! # Why the stamp is taken before the backfill
//!
//! The backfill adds versions and terms itself, so a stamp read afterwards
//! would describe its own writes. Read afterwards it could also describe an
//! image another connection inserted between the backfill's read and the
//! stamp's — and record that image as covered when it was not. Read before,
//! the worst case is the reverse: the backfill's own inserts move the stamp,
//! and the next open runs one more backfill that finds nothing. That costs one
//! redundant pass after a backfill that did real work, and never misses a row.
//!
//! # What it does not see
//!
//! An `UPDATE` that creates work without adding a row. None of this build's
//! writers does: a scan's move of a file is a new `source_ref` and so a new
//! image, and uuids are only rewritten by the backfill itself. Should one
//! appear, the cost is that its repair waits for the next insert or the next
//! start of the app — which is exactly where the backfill ran before it ran on
//! every open.
//!
//! Kept in memory rather than in the catalog on purpose: a row in the file
//! would travel in the sync snapshot and would need a table an older build
//! does not have, and a flag that another device's catalog carried in would
//! say nothing about this one.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use rusqlite::Connection;
use crate::error::CatalogError;
/// What a catalog looked like, as far as the backfill cares.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Stamp {
/// Device and inode, so a file swapped in under the same name is a new
/// catalog. `None` where the platform has no such thing.
file: Option<(u64, u64)>,
user_version: i64,
/// The newest image: id, path, when added, and whether it has a version.
last_image: Option<String>,
/// The newest version: id and the image it belongs to.
last_version: Option<String>,
/// The newest keyword assignment: rowid, version and word.
last_keyword: Option<String>,
}
/// The stamp recorded at the last backfill, per catalog file.
fn done() -> &'static Mutex<HashMap<PathBuf, Stamp>> {
static DONE: OnceLock<Mutex<HashMap<PathBuf, Stamp>>> = OnceLock::new();
DONE.get_or_init(Default::default)
}
/// One name per file, whichever spelling of its path the caller used.
fn key(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
/// Read the stamp of the catalog behind `conn`, which was opened from `path`.
///
/// One statement: the last row of each of three tables, each found by
/// descending its rowid b-tree to the last page, plus one probe of
/// `versions_image` for the newest image — and a `stat` of the file.
pub(crate) fn stamp(conn: &Connection, path: &Path) -> Result<Stamp, CatalogError> {
let (user_version, last_image, last_version, last_keyword) = conn.query_row(
"SELECT (SELECT user_version FROM pragma_user_version),
(SELECT printf('%d|%d|%d|%s', i.id, i.added_at,
EXISTS (SELECT 1 FROM versions v WHERE v.image_id = i.id),
i.source_ref)
FROM images i ORDER BY i.id DESC LIMIT 1),
(SELECT printf('%d|%d', id, image_id)
FROM versions ORDER BY id DESC LIMIT 1),
(SELECT printf('%d|%d|%s', rowid, version_id, keyword)
FROM keywords ORDER BY rowid DESC LIMIT 1)",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)?;
Ok(Stamp {
file: file_identity(path),
user_version,
last_image,
last_version,
last_keyword,
})
}
#[cfg(unix)]
fn file_identity(path: &Path) -> Option<(u64, u64)> {
use std::os::unix::fs::MetadataExt;
std::fs::metadata(path).ok().map(|m| (m.dev(), m.ino()))
}
#[cfg(not(unix))]
fn file_identity(_path: &Path) -> Option<(u64, u64)> {
None
}
/// Whether the catalog at `path` was last backfilled at exactly `stamp`.
pub(crate) fn is_current(path: &Path, stamp: &Stamp) -> bool {
done()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&key(path))
== Some(stamp)
}
/// Record that the catalog at `path` has been backfilled as of `stamp`.
pub(crate) fn record(path: &Path, stamp: Stamp) {
done()
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key(path), stamp);
}
/// Make the next open of `path` backfill, whatever its stamp says.
///
/// For the writers that know they have changed the catalog wholesale — a
/// merge of a pulled catalog, a restore from backup — so their correctness
/// does not rest on the stamp happening to move.
pub(crate) fn forget(path: &Path) {
done()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&key(path));
}
#[cfg(test)]
mod tests {
use crate::rating::derived_version_uuid;
use crate::Catalog;
use std::path::PathBuf;
/// A catalog file of its own, holding one image the server has named,
/// backfilled and settled.
///
/// Opened three times on the way: to create it; after the image went in,
/// which gives the image its default version; and once more, because that
/// version moved the stamp and the next open runs the one redundant pass
/// the module header describes. After that the stamp stands still.
fn catalog(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"dr-backfilled-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("catalog.sqlite");
{
let cat = Catalog::open(&path).unwrap();
let c = cat.connection();
c.execute_batch(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'Photos');
INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (1, 1, 'Photos/a.CR3', 0);
INSERT INTO remote(image_id, file_id) VALUES (1, 77);",
)
.unwrap();
}
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
path
}
/// The default version's uuid for `image`, read through an ordinary open.
fn uuid(path: &std::path::Path, image: i64) -> Option<String> {
let cat = Catalog::open(path).unwrap();
cat.connection()
.query_row(
"SELECT uuid FROM versions WHERE image_id = ?1 AND is_default = 1",
[image],
|r| r.get(0),
)
.ok()
}
/// Put the one row back into the state the backfill repairs, with an
/// `UPDATE` — which moves none of the stamp's maxima, so only the stamp's
/// other parts or an explicit `forget` can bring the backfill back.
fn unalign(path: &std::path::Path) {
rusqlite::Connection::open(path)
.unwrap()
.execute("UPDATE versions SET uuid = 'minted' WHERE image_id = 1", [])
.unwrap();
}
#[test]
fn an_unchanged_catalog_is_not_backfilled_again() {
let path = catalog("unchanged");
unalign(&path);
assert_eq!(
uuid(&path, 1).as_deref(),
Some("minted"),
"nothing was added since the last backfill, so the open skipped it"
);
}
#[test]
fn an_image_a_scan_added_is_backfilled_on_the_next_open() {
let path = catalog("scanned");
rusqlite::Connection::open(&path)
.unwrap()
.execute(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (2, 1, 'Photos/b.CR3', 0)",
[],
)
.unwrap();
assert!(uuid(&path, 2).is_some(), "the new image got its version");
}
/// The id of a deleted newest row is handed out again, so `max(id)` is
/// the same before and after — the sequence emptying the trash and then
/// scanning makes. The image that took the id still needs its version.
///
/// A virtual copy on the older image holds the newest version id, so
/// the deletion does not move `max(versions.id)` either: nothing the old
/// stamp read changes, which is the case that went unrepaired.
#[test]
fn an_image_that_reuses_a_deleted_id_is_backfilled_on_the_next_open() {
let path = catalog("reused");
rusqlite::Connection::open(&path)
.unwrap()
.execute(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (2, 1, 'Photos/b.CR3', 0)",
[],
)
.unwrap();
assert!(uuid(&path, 2).is_some());
rusqlite::Connection::open(&path)
.unwrap()
.execute(
"INSERT INTO versions(image_id, uuid, name, is_default)
VALUES (1, 'copy', 'Crop', 0)",
[],
)
.unwrap();
// Settle: one open backfills after the new version, one more runs
// the redundant pass and records the stamp that stands.
assert!(uuid(&path, 2).is_some());
assert!(uuid(&path, 2).is_some());
let c = rusqlite::Connection::open(&path).unwrap();
let before: (i64, i64) = c
.query_row(
"SELECT (SELECT max(id) FROM images), (SELECT max(id) FROM versions)",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
c.execute("DELETE FROM images WHERE id = 2", []).unwrap();
c.execute(
"INSERT INTO images(root_id, source_ref, added_at)
VALUES (1, 'Photos/c.CR3', 0)",
[],
)
.unwrap();
let after: (i64, i64) = c
.query_row(
"SELECT (SELECT max(id) FROM images), (SELECT max(id) FROM versions)",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(before, after, "SQLite handed the freed id out again");
drop(c);
assert!(uuid(&path, 2).is_some(), "the new image got its version");
}
/// The same, with the same file coming back at the same id in the same
/// second: path and time match, and only the missing version tells.
#[test]
fn the_same_file_back_at_the_same_id_is_backfilled_on_the_next_open() {
let path = catalog("returned");
let c = rusqlite::Connection::open(&path).unwrap();
// As above: a newer version on another image keeps the deletion
// from moving `max(versions.id)`.
c.execute_batch(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (0, 1, 'Photos/0.CR3', 0);
INSERT INTO versions(image_id, uuid, name, is_default)
VALUES (0, 'copy', 'Crop', 0);",
)
.unwrap();
drop(c);
assert!(uuid(&path, 1).is_some());
assert!(uuid(&path, 1).is_some());
let c = rusqlite::Connection::open(&path).unwrap();
c.execute_batch(
"DELETE FROM images WHERE id = 1;
INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (1, 1, 'Photos/a.CR3', 0);
INSERT INTO remote(image_id, file_id) VALUES (1, 77);",
)
.unwrap();
drop(c);
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
}
#[test]
fn the_first_open_after_a_migration_backfills() {
let path = catalog("migrated");
unalign(&path);
rusqlite::Connection::open(&path)
.unwrap()
.pragma_update(None, "user_version", crate::schema::SCHEMA_VERSION - 1)
.unwrap();
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
}
#[test]
fn the_first_open_after_a_pulled_catalog_backfills() {
let path = catalog("pulled");
// The remote is this catalog as it stands, so every row the merge
// offers collides and nothing in the stamp moves: only the merge
// saying so can make the next open backfill.
let remote = path.with_file_name("remote.sqlite");
rusqlite::Connection::open(&path)
.unwrap()
.execute("VACUUM INTO ?1", [remote.to_string_lossy().as_ref()])
.unwrap();
unalign(&path);
assert_eq!(uuid(&path, 1).as_deref(), Some("minted"));
Catalog::open(&path)
.unwrap()
.merge_remote_catalog(&remote)
.unwrap();
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
}
#[cfg(unix)]
#[test]
fn a_catalog_replaced_under_the_same_name_backfills() {
let path = catalog("replaced");
unalign(&path);
assert_eq!(uuid(&path, 1).as_deref(), Some("minted"));
// Every connection is closed, so the WAL is folded in and the main
// file is the whole catalog. A copy renamed over it is the same rows
// in a new file — which is what a restore or a copied-in catalog is.
let copy = path.with_file_name("copy.sqlite");
std::fs::copy(&path, &copy).unwrap();
std::fs::rename(&copy, &path).unwrap();
assert_eq!(uuid(&path, 1), Some(derived_version_uuid(77)));
}
}
+65
View File
@@ -722,6 +722,31 @@ pub fn not_collapsed_away(image: &str) -> String {
) )
} }
/// SQL for the rows [`not_collapsed_away`] drops, as a `FROM ... WHERE`
/// joining each such frame to its image under the alias `image`.
///
/// For counting. A count that applies [`not_collapsed_away`] to every row
/// pays two primary-key probes per image to find the handful a collapsed
/// burst hides; counting everything and subtracting what this lists walks
/// only `burst_members`, which is empty on a library without bursts. The
/// caller appends its own conditions on `image` with `AND`, the same ones
/// it counted the whole with, so the subtraction takes away only rows the
/// whole included. `image_id` is `burst_members`' key, so no image is
/// listed twice.
///
/// The two must describe the same rows: change one, change both, and
/// `the_collapsed_frames_are_what_the_predicate_drops` will say if they drift.
///
/// Never interpolate anything user-supplied as `image`.
pub fn collapsed_away_frames(image: &str) -> String {
format!(
"burst_members bm CROSS JOIN images {image} ON {image}.id = bm.image_id
WHERE bm.representative = 0
AND NOT EXISTS (SELECT 1 FROM burst_expanded be
WHERE be.burst_id = bm.burst_id)"
)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1235,6 +1260,46 @@ mod tests {
assert_eq!(visible(cat.connection()), vec![1, 2, 3, 4]); assert_eq!(visible(cat.connection()), vec![1, 2, 3, 4]);
} }
#[test]
fn the_collapsed_frames_are_what_the_predicate_drops() {
// `collapsed_away_frames` is `not_collapsed_away` turned inside out
// for counting; the two must name the same rows, open or closed.
let cat = seeded(&[
(1, 1000, Some(0xFF00)),
(2, 1001, Some(0xFF00)),
(3, 1002, Some(0xFF00)),
(4, 9000, Some(0xAA00)),
(5, 9001, Some(0xAA00)),
(6, 20000, Some(0xFF00)),
]);
regroup(cat.connection(), Rules::default()).unwrap();
let ids = |sql: String| -> Vec<i64> {
let c = cat.connection();
let mut stmt = c.prepare(&sql).unwrap();
let rows = stmt.query_map([], |r| r.get::<_, i64>(0)).unwrap();
rows.collect::<Result<Vec<_>, _>>().unwrap()
};
let dropped = || {
ids(format!(
"SELECT id FROM images i WHERE NOT {} ORDER BY id",
not_collapsed_away("i")
))
};
let listed = || {
ids(format!(
"SELECT i.id FROM {} ORDER BY i.id",
collapsed_away_frames("i")
))
};
assert_eq!(listed(), dropped());
set_expanded(cat.connection(), ImageId(1), false).unwrap();
assert_eq!(dropped(), vec![2, 3]);
assert_eq!(listed(), dropped());
set_expanded(cat.connection(), ImageId(4), false).unwrap();
assert_eq!(listed(), dropped());
}
#[test] #[test]
fn a_library_with_no_bursts_hides_nothing() { fn a_library_with_no_bursts_hides_nothing() {
// The predicate is in every grid query, so its cost and its effect on a // The predicate is in every grid query, so its cost and its effect on a
File diff suppressed because it is too large Load Diff
+10
View File
@@ -65,6 +65,16 @@ pub enum CatalogError {
#[error("no such collection: {0}")] #[error("no such collection: {0}")]
NoSuchCollection(u64), NoSuchCollection(u64),
/// An album the caller named is gone — deleted here, or by a merge while
/// its id sat in a UI model.
#[error("no such album: {0}")]
NoSuchAlbum(u64),
/// A name that is empty once trimmed. Refused rather than stored, because
/// a row with no name is one the sidebar cannot draw and nobody can pick.
#[error("a name is required")]
EmptyName,
/// A keyword the caller named is gone — deleted, or fused into another by a /// A keyword the caller named is gone — deleted, or fused into another by a
/// merge while its id sat in a UI model. /// merge while its id sat in a UI model.
/// ///
+109 -7
View File
@@ -1029,7 +1029,8 @@ fn people_where(conn: &Connection, in_use: bool) -> Result<Vec<Person>, CatalogE
/// ///
/// Confirmations survive the move: a face the user confirmed as the source /// Confirmations survive the move: a face the user confirmed as the source
/// person is now a confirmed face of the target, which is what the user meant /// person is now a confirmed face of the target, which is what the user meant
/// by saying they are the same person. /// by saying they are the same person. So do rejections — see
/// [`merge_people_within`].
pub fn merge_people( pub fn merge_people(
conn: &Connection, conn: &Connection,
target: PersonId, target: PersonId,
@@ -1039,7 +1040,53 @@ pub fn merge_people(
return Ok(0); return Ok(0);
} }
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
let moved = merge_people_within(&tx, target, source)?;
tx.commit()?;
Ok(moved)
}
/// [`merge_people`] inside a transaction the caller holds, so a job that
/// merges several pairs commits once (`crate::dedup_people`).
///
/// **Rejections move with the faces.** "This face is not Annie" is a
/// judgement about the person, and once Annie is Anna it is one about Anna.
/// Left on the redirect it binds nothing, and the next grouping pass
/// suggests the face the user pushed away to the person it now belongs to. Where the
/// two halves disagree about one face — confirmed as one, rejected as the
/// other — the confirmation stands, which is the rule [`confirm`] applies
/// to one face; and a moved rejection takes a suggestion of the same face
/// with it, the rule [`reject`] applies.
pub(crate) fn merge_people_within(
tx: &Connection,
target: PersonId,
source: PersonId,
) -> Result<u64, CatalogError> {
if target == source {
return Ok(0);
}
let moved = move_judgements(tx, target, source)?;
tx.execute(
"UPDATE people SET merged_into = ?1, revision = revision + 1, modified = ?3
WHERE id = ?2",
rusqlite::params![target.0 as i64, source.0 as i64, now_secs()],
)?;
Ok(moved)
}
/// The half of [`merge_people_within`] that moves faces and rejections,
/// without touching either person's row.
///
/// Also what follows a redirect that arrived by sync
/// (`crate::dedup_people`): the other device merged the people, and this
/// one still holds judgements on the person merged away. Bumping the
/// person's revision there would be an edit of this device's own, sent back
/// on every pass, so the row is left as the merge wrote it.
pub(crate) fn move_judgements(
tx: &Connection,
target: PersonId,
source: PersonId,
) -> Result<u64, CatalogError> {
let (t, s) = (target.0 as i64, source.0 as i64);
// A face already assigned to the target must not gain a second row — // A face already assigned to the target must not gain a second row —
// `face_person` is keyed by face. Where both hold the same face, the // `face_person` is keyed by face. Where both hold the same face, the
// target's row wins and the source's is dropped. // target's row wins and the source's is dropped.
@@ -1047,18 +1094,31 @@ pub fn merge_people(
"DELETE FROM face_person "DELETE FROM face_person
WHERE person_id = ?2 WHERE person_id = ?2
AND face_id IN (SELECT face_id FROM face_person WHERE person_id = ?1)", AND face_id IN (SELECT face_id FROM face_person WHERE person_id = ?1)",
rusqlite::params![target.0 as i64, source.0 as i64], rusqlite::params![t, s],
)?; )?;
let moved = tx.execute( let moved = tx.execute(
"UPDATE face_person SET person_id = ?1 WHERE person_id = ?2", "UPDATE face_person SET person_id = ?1 WHERE person_id = ?2",
rusqlite::params![target.0 as i64, source.0 as i64], rusqlite::params![t, s],
)?; )?;
tx.execute( tx.execute(
"UPDATE people SET merged_into = ?1, revision = revision + 1, modified = ?3 "INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
WHERE id = ?2", SELECT face_id, ?1 FROM face_person_rejected WHERE person_id = ?2",
rusqlite::params![target.0 as i64, source.0 as i64, now_secs()], rusqlite::params![t, s],
)?;
tx.execute("DELETE FROM face_person_rejected WHERE person_id = ?1", [s])?;
tx.execute(
"DELETE FROM face_person_rejected
WHERE person_id = ?1
AND face_id IN (SELECT face_id FROM face_person
WHERE person_id = ?1 AND confirmed = 1)",
[t],
)?;
tx.execute(
"DELETE FROM face_person
WHERE person_id = ?1 AND confirmed = 0
AND face_id IN (SELECT face_id FROM face_person_rejected WHERE person_id = ?1)",
[t],
)?; )?;
tx.commit()?;
Ok(moved as u64) Ok(moved as u64)
} }
@@ -2518,6 +2578,48 @@ mod tests {
assert_eq!(people(&c).unwrap().len(), 1); assert_eq!(people(&c).unwrap().len(), 1);
} }
/// A rejection left on the redirect bound nothing: the next grouping
/// pass suggested the face to the merged person, whom the user had told
/// it was somebody else.
#[test]
fn merging_moves_the_rejections_too() {
let c = db();
let ids: Vec<FaceId> = (1..=3)
.map(|n| {
let img = image(&c, n);
record_detections(&c, img, "w600k_mbf", 1024, &[face(n as u8)]).unwrap()[0]
})
.collect();
let anna = create_person(&c, "Anna").unwrap();
let annie = create_person(&c, "Annie").unwrap();
// Rejected as Annie, and nothing said about Anna.
reject(&c, ids[0], annie).unwrap();
// Rejected as Annie, suggested as Anna: the rejection now covers it.
suggest(&c, ids[1], anna, 0.8).unwrap();
reject(&c, ids[1], annie).unwrap();
// Rejected as Annie, confirmed as Anna: the confirmation stands.
confirm(&c, ids[2], anna).unwrap();
reject(&c, ids[2], annie).unwrap();
merge_people(&c, anna, annie).unwrap();
let rejected: Vec<(i64, i64)> = c
.prepare("SELECT face_id, person_id FROM face_person_rejected ORDER BY face_id")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
let anna_id = anna.0 as i64;
assert_eq!(
rejected,
[(ids[0].0 as i64, anna_id), (ids[1].0 as i64, anna_id)]
);
assert_eq!(for_image(&c, ImageId(2)).unwrap()[0].person, None);
let kept = &for_image(&c, ImageId(3)).unwrap()[0];
assert_eq!((kept.person, kept.confirmed), (Some(anna), true));
}
#[test] #[test]
fn merging_does_not_duplicate_a_face_both_people_hold() { fn merging_does_not_duplicate_a_face_both_people_hold() {
let c = db(); let c = db();
+44 -2
View File
@@ -29,7 +29,11 @@ pub enum JobKind {
ScanFolder = 0, ScanFolder = 0,
/// Promote an image from stat-only to full EXIF. /// Promote an image from stat-only to full EXIF.
ExtractMetadata = 1, ExtractMetadata = 1,
/// Build or rebuild a thumbnail. /// Build or rebuild a thumbnail. **Retired** — see [`JobKind::RETIRED`].
///
/// Kept so the number stays taken: a catalog written by 0.16.0 or earlier
/// holds rows of kind 2, and reusing it would hand them to whatever took
/// its place.
Thumbnail = 2, Thumbnail = 2,
/// A sidecar on disk is newer than what the catalog read. /// A sidecar on disk is newer than what the catalog read.
ReadSidecar = 3, ReadSidecar = 3,
@@ -69,6 +73,18 @@ impl JobKind {
JobKind::DetectFaces, JobKind::DetectFaces,
]; ];
/// Kinds that are no longer queued by anything, whose rows are deleted on
/// sight by [`drop_retired`].
///
/// `Thumbnail` is here because thumbnails are owed by the store, not by
/// the queue. The grid's worker and the thumbnail sweep both find their
/// work by asking `ThumbStore` what it lacks, and the store is shared
/// between devices, so it is the only thing that can say another device
/// already made one. Up to 0.16.0 every scan enqueued a job per
/// photograph anyway and no handler ever claimed one: the reference
/// catalog held 23,582 of them (#73; catalog.md §6.1).
pub const RETIRED: [JobKind; 1] = [JobKind::Thumbnail];
fn from_i64(v: i64) -> Option<Self> { fn from_i64(v: i64) -> Option<Self> {
Some(match v { Some(match v {
0 => JobKind::ScanFolder, 0 => JobKind::ScanFolder,
@@ -399,7 +415,7 @@ pub fn recover_orphaned(conn: &Connection) -> Result<usize, CatalogError> {
/// ///
/// Coalescing keeps the table one row per unit of work, but nothing shrinks it /// Coalescing keeps the table one row per unit of work, but nothing shrinks it
/// when the work stops existing: a library that has been culled carries a /// when the work stops existing: a library that has been culled carries a
/// thumbnail job for every photograph deleted since the last time anything /// job for every photograph deleted since the last time anything
/// looked. Each one would be claimed, run, and failed five times. /// looked. Each one would be claimed, run, and failed five times.
/// ///
/// Only kinds whose subject really is an image ([`JobKind::subject_is_image`]) /// Only kinds whose subject really is an image ([`JobKind::subject_is_image`])
@@ -431,6 +447,32 @@ pub fn reap_orphan_subjects(conn: &Connection) -> Result<usize, CatalogError> {
Ok(n) Ok(n)
} }
/// Delete every row of a [`JobKind::RETIRED`] kind.
///
/// Not a migration, deliberately. A schema bump makes an older build refuse
/// the synced catalog snapshot, and a device still on 0.16.0 would lose the
/// catalog to save a megabyte. So this runs where the queue is readied —
/// [`crate::runner::recover`], at every open — and has to be cheap when there
/// is nothing to do: `kind` leads the `UNIQUE(kind, subject_id)` index, so an
/// empty answer is one index probe, not a table scan.
///
/// Every open rather than once, because once is not enough: an older build
/// opening the same catalog enqueues them again on its next scan.
///
/// Rows in any state go. Nothing claims these kinds, so none can be running,
/// and a failed one would be a report about work nobody was going to do.
pub fn drop_retired(conn: &Connection) -> Result<usize, CatalogError> {
let kinds: Vec<i64> = JobKind::RETIRED.iter().map(|k| *k as i64).collect();
let placeholders = std::iter::repeat_n("?", kinds.len())
.collect::<Vec<_>>()
.join(",");
let n = conn.execute(
&format!("DELETE FROM jobs WHERE kind IN ({placeholders})"),
rusqlite::params_from_iter(kinds.iter()),
)?;
Ok(n)
}
/// How much is left, by state. /// How much is left, by state.
/// ///
/// One query rather than a listing, because the caller is a progress line: a /// One query rather than a listing, because the caller is a progress line: a
+23 -1
View File
@@ -244,7 +244,8 @@ pub fn delete(conn: &Connection, id: KeywordId) -> Result<usize, CatalogError> {
/// every assignment, and a query per keyword would be one statement per word /// every assignment, and a query per keyword would be one statement per word
/// in the library. /// in the library.
pub fn list(conn: &Connection) -> Result<Vec<Keyword>, CatalogError> { pub fn list(conn: &Connection) -> Result<Vec<Keyword>, CatalogError> {
let mut stmt = conn.prepare( ensure_term_index(conn);
let mut stmt = conn.prepare_cached(
// DISTINCT image, not row: a word on two versions of one frame is one // DISTINCT image, not row: a word on two versions of one frame is one
// photograph, and reporting two is the kind of small lie that makes a // photograph, and reporting two is the kind of small lie that makes a
// user stop trusting the counts. // user stop trusting the counts.
@@ -269,6 +270,27 @@ pub fn list(conn: &Connection) -> Result<Vec<Keyword>, CatalogError> {
Ok(rows) Ok(rows)
} }
/// The index [`list`]'s per-word count is served from: `keywords_term`
/// with the version beside the word, so the count reads no `keywords` row.
///
/// `keywords_term` alone gave the row id, and each of the 10,800 assignments
/// on the reference library cost a probe of the table for its version --
/// 4 ms of the keyword panel's redraw, on every selection change.
///
/// Created on first use rather than by a migration, for the reason
/// `duplicates::ensure_probe_table` gives: a new schema version makes every
/// older build refuse this catalog's snapshot at sync, and an older build
/// that meets an extra index ignores it. Once it exists, the statement is a
/// lookup in the schema (microseconds). A failure to create it is logged and
/// the list read without it: the index is a speed-up, never an answer.
fn ensure_term_index(conn: &Connection) {
if let Err(e) = conn.execute_batch(
"CREATE INDEX IF NOT EXISTS keywords_term_version ON keywords(keyword, version_id);",
) {
log::warn!("keywords: could not create keywords_term_version: {e}");
}
}
/// Assign a keyword to images, creating the keyword if it is new. /// Assign a keyword to images, creating the keyword if it is new.
/// ///
/// The bulk form is the *only* form, because keywording a selection is the /// The bulk form is the *only* form, because keywording a selection is the
+16 -2
View File
@@ -36,10 +36,13 @@ use std::path::Path;
use dr_types::{Availability, ImageId}; use dr_types::{Availability, ImageId};
use rusqlite::Connection; use rusqlite::Connection;
pub mod albums;
mod backfilled;
pub mod bursts; pub mod bursts;
pub mod cache; pub mod cache;
pub mod collections; pub mod collections;
pub mod dedup; pub mod dedup;
pub mod dedup_people;
pub mod duplicates; pub mod duplicates;
pub mod error; pub mod error;
pub mod face_shard; pub mod face_shard;
@@ -57,6 +60,7 @@ pub mod sync;
pub mod trash; pub mod trash;
pub mod walk; pub mod walk;
pub use albums::{Album, AlbumId, Place};
pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES}; pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
pub use collections::{Collection, CollectionKind, TreeRow}; pub use collections::{Collection, CollectionKind, TreeRow};
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash}; pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
@@ -247,8 +251,18 @@ impl Catalog {
// A migration adds a column; it cannot know what the value should be // A migration adds a column; it cannot know what the value should be
// for rows that already existed. Backfilling on open is what stops // for rows that already existed. Backfilling on open is what stops
// those rows being silently partial. // those rows being silently partial.
for (what, n) in schema::backfill(&conn)? { //
log::info!("backfilled {what} for {n} row(s) (schema was v{from})"); // Once per catalog state rather than once per open (NFR-P9): every
// worker thread opens its own connection, and a develop landing made
// five, each paying the whole backfill to confirm nothing had changed.
// [`backfilled`] says what "changed" means and why it is enough. A
// migration always backfills, stamp or no stamp.
let stamp = backfilled::stamp(&conn, path)?;
if from < schema::SCHEMA_VERSION || !backfilled::is_current(path, &stamp) {
for (what, n) in schema::backfill(&conn)? {
log::info!("backfilled {what} for {n} row(s) (schema was v{from})");
}
backfilled::record(path, stamp);
} }
Ok(Catalog { conn }) Ok(Catalog { conn })
} }
File diff suppressed because it is too large Load Diff
+156 -17
View File
@@ -449,19 +449,26 @@ pub fn toggled_label(
/// TRACES: FR-CAT-5 | FR-CAT-6 /// TRACES: FR-CAT-5 | FR-CAT-6
/// How the library divides by colour label, for the filter chips' counts. /// How the library divides by colour label, for the filter chips' counts.
/// ///
/// Index 0 is unlabelled and index `n` the label whose code is `n`. One /// Index 0 is unlabelled and index `n` the label whose code is `n`. The
/// grouped statement — the same shape as [`rating_histogram`], and for the /// same shape as [`rating_histogram`], and for the same reason the
/// same reason it LEFT JOINs: an image without a version row is unlabelled, /// unlabelled slot is what is left of [`judged_rows`]: an image without a
/// not missing. /// version row is unlabelled, not missing.
///
/// Only labelled rows are grouped. The join this replaced (2026-09-26)
/// probed `versions_judgement` per image and then read each version's row
/// for `label`, which the index does not carry -- 10 ms on the reference
/// library, on every label keystroke, to find that none of 23,500 images
/// had one. This walks the default versions in the index's order, which
/// is close to the table's, and groups the few that are labelled.
pub fn label_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> { pub fn label_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
let mut out = [0usize; 6]; let mut out = [0usize; 6];
let mut stmt = conn.prepare( let mut stmt = conn.prepare_cached(
"SELECT coalesce(v.label, 0) AS l, count(*) "SELECT label, count(*) FROM versions
FROM images i WHERE is_default = 1 AND label IS NOT NULL
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1 GROUP BY label",
GROUP BY l",
)?; )?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?; let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
let mut counted = 0usize;
for (code, count) in rows.flatten() { for (code, count) in rows.flatten() {
// A code this build does not know counts as unlabelled, which is how // A code this build does not know counts as unlabelled, which is how
// `label_from_code` reads it everywhere else. // `label_from_code` reads it everywhere else.
@@ -471,7 +478,9 @@ pub fn label_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
0 0
}; };
out[slot] += count as usize; out[slot] += count as usize;
counted += count as usize;
} }
out[0] += judged_rows(conn)?.saturating_sub(counted);
Ok(out) Ok(out)
} }
@@ -582,25 +591,61 @@ pub fn judgements(
pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> { pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
let mut out = [0usize; 6]; let mut out = [0usize; 6];
// LEFT JOIN, so an image whose version row is missing still counts as // Only the rated rows are grouped; the unrated slot is what is left of
// unrated rather than vanishing from the totals. The histogram has to sum // [`judged_rows`]. So an image whose version row is missing still counts
// to the library size or it is not believable. // as unrated rather than vanishing from the totals -- the histogram has
let mut stmt = conn.prepare( // to sum to the library size or it is not believable.
"SELECT coalesce(v.rating, 0) AS r, count(*) //
FROM images i // It was one `images LEFT JOIN versions ... GROUP BY` until 2026-09-26:
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1 // a probe of `versions_judgement` per image and a sort of every row, to
GROUP BY r", // put 22,000 of 23,500 in slot zero. 9 ms on every star keystroke on the
// reference library; this is a pass over the index that sorts only the
// rated few, and [`judged_rows`] is three index-only counts.
let mut stmt = conn.prepare_cached(
"SELECT rating, count(*) FROM versions
WHERE is_default = 1 AND rating != 0
GROUP BY rating",
)?; )?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?; let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
let mut counted = 0usize;
for (rating, count) in rows.flatten() { for (rating, count) in rows.flatten() {
if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) { if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) {
*slot += count as usize; *slot += count as usize;
counted += count as usize;
} }
} }
out[0] += judged_rows(conn)?.saturating_sub(counted);
Ok(out) Ok(out)
} }
/// How many rows `images LEFT JOIN versions ON ... AND is_default = 1` has:
/// one per image with no default version, and one per default version for
/// the rest. The total both histograms divide up, and the unjudged slot is
/// what is left of it once the judged rows are counted.
///
/// Spelled as three counts rather than as that join because the join probes
/// `versions_judgement` once per image, where each count here is one pass
/// over an index without reading a row: the library size, the default
/// versions, and the images holding one. An image with two default versions
/// -- nothing prevents it -- is two rows of the join and one image of the
/// third count, so it adds one here exactly as it did there. A version
/// always belongs to an image; `foreign_keys` is on and deletes cascade.
///
/// `count(DISTINCT image_id)` alone in its statement: that is what lets
/// SQLite read the distinct values off the index's order instead of
/// building a temporary b-tree of them.
fn judged_rows(conn: &Connection) -> Result<usize, CatalogError> {
let n: i64 = conn
.prepare_cached(
"SELECT (SELECT count(*) FROM images)
+ (SELECT count(*) FROM versions WHERE is_default = 1)
- (SELECT count(DISTINCT image_id) FROM versions WHERE is_default = 1)",
)?
.query_row([], |r| r.get(0))?;
Ok(n.max(0) as usize)
}
/// How many images carry each flag: `(picks, rejects)`. /// How many images carry each flag: `(picks, rejects)`.
pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> { pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> {
let picks: i64 = conn.query_row( let picks: i64 = conn.query_row(
@@ -987,6 +1032,100 @@ mod tests {
assert_eq!(h.iter().sum::<usize>(), 4); assert_eq!(h.iter().sum::<usize>(), 4);
} }
/// The rows of the join the histograms used to be spelled as, grouped the
/// way `rating_histogram` groups them. What the counts must still agree
/// with, in the states nothing in the schema prevents.
fn by_join(cat: &Catalog, column: &str) -> Vec<(i64, i64)> {
cat.connection()
.prepare(&format!(
"SELECT coalesce(v.{column}, 0) AS c, count(*)
FROM images i
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
GROUP BY c ORDER BY c"
))
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.map(Result::unwrap)
.collect()
}
/// A library in every awkward state at once: an image with no version,
/// one with only a virtual copy, one with two default versions, and
/// values out of range on both axes.
fn awkward() -> Catalog {
let cat = with_images(8);
ensure_default_versions(cat.connection()).unwrap();
let all = ids(&cat);
let c = cat.connection();
set_rating(c, all[0], 5).unwrap();
set_rating(c, all[1], 2).unwrap();
set_label(c, all[1], Some(ColourLabel::Blue)).unwrap();
c.execute(
"DELETE FROM versions WHERE image_id = ?1",
[all[2].0 as i64],
)
.unwrap();
c.execute(
"UPDATE versions SET is_default = 0 WHERE image_id = ?1",
[all[3].0 as i64],
)
.unwrap();
c.execute(
"INSERT INTO versions(image_id, uuid, name, is_default, rating, label)
VALUES (?1, 'second-default', 'Copy', 1, 4, 3)",
[all[4].0 as i64],
)
.unwrap();
c.execute(
"UPDATE versions SET rating = -1, label = 9 WHERE image_id = ?1",
[all[5].0 as i64],
)
.unwrap();
c.execute(
"UPDATE versions SET rating = 7, label = 0 WHERE image_id = ?1",
[all[6].0 as i64],
)
.unwrap();
cat
}
/// Fold the join's rows into slots the way the old code did.
fn folded(rows: &[(i64, i64)], slot: impl Fn(i64) -> usize) -> [usize; 6] {
let mut out = [0usize; 6];
for &(code, n) in rows {
out[slot(code)] += n as usize;
}
out
}
#[test]
fn the_rating_histogram_agrees_with_the_join_it_replaced() {
let cat = awkward();
let expected = folded(&by_join(&cat, "rating"), |r| {
r.clamp(0, MAX_RATING as i64) as usize
});
assert_eq!(rating_histogram(cat.connection()).unwrap(), expected);
// Nine rows for eight images: the doubled default counts twice, as
// it always has.
assert_eq!(expected.iter().sum::<usize>(), 9);
}
#[test]
fn the_label_histogram_agrees_with_the_join_it_replaced() {
let cat = awkward();
let expected = folded(&by_join(&cat, "label"), |code| {
if label_from_code(Some(code)).is_some() {
code as usize
} else {
0
}
});
assert_eq!(label_histogram(cat.connection()).unwrap(), expected);
assert_eq!(expected[3], 1, "the second default's label is counted");
assert_eq!(expected.iter().sum::<usize>(), 9);
}
#[test] #[test]
fn flag_counts_separate_picks_from_rejects() { fn flag_counts_separate_picks_from_rejects() {
let cat = with_images(5); let cat = with_images(5);
+4
View File
@@ -322,6 +322,10 @@ pub fn restore(catalog: &Path, backup: &Path) -> Result<(), CatalogError> {
/// to move — a caller may be recovering from a file SQLite could not open /// to move — a caller may be recovering from a file SQLite could not open
/// because it was never created. /// because it was never created.
pub fn set_aside(catalog: &Path) -> Result<Option<PathBuf>, CatalogError> { pub fn set_aside(catalog: &Path) -> Result<Option<PathBuf>, CatalogError> {
// Whatever takes this name next — a rebuild or a restored backup — is not
// the file this process last backfilled. Forgotten while the path still
// resolves, so it is the same key the open recorded.
crate::backfilled::forget(catalog);
let moved = if catalog.exists() { let moved = if catalog.exists() {
let dest = with_suffix(catalog, DAMAGED_SUFFIX); let dest = with_suffix(catalog, DAMAGED_SUFFIX);
// An earlier damaged copy is replaced rather than accumulating: two of // An earlier damaged copy is replaced rather than accumulating: two of
+57 -11
View File
@@ -77,7 +77,7 @@ pub enum Outcome {
/// Something that can actually do the work a job describes. /// Something that can actually do the work a job describes.
/// ///
/// The catalog knows what needs doing and nothing about how — a thumbnail /// The catalog knows what needs doing and nothing about how — face detection
/// needs a decoder, a fetch needs a network stack, and neither belongs under /// needs a decoder, a fetch needs a network stack, and neither belongs under
/// `core/dr-catalog` (ARCH §4.1: calls go downward). So the queue lives here /// `core/dr-catalog` (ARCH §4.1: calls go downward). So the queue lives here
/// and the handlers are supplied from above. /// and the handlers are supplied from above.
@@ -187,17 +187,19 @@ pub struct Recovered {
pub reclaimed: usize, pub reclaimed: usize,
/// Jobs deleted because the photograph they name no longer exists. /// Jobs deleted because the photograph they name no longer exists.
pub reaped: usize, pub reaped: usize,
/// Jobs deleted because their kind is retired ([`JobKind::RETIRED`]).
pub retired: usize,
} }
impl Recovered { impl Recovered {
pub fn did_anything(&self) -> bool { pub fn did_anything(&self) -> bool {
self.reclaimed > 0 || self.reaped > 0 self.reclaimed > 0 || self.reaped > 0 || self.retired > 0
} }
} }
/// Ready the queue for a fresh run, before any worker touches it. /// Ready the queue for a fresh run, before any worker touches it.
/// ///
/// Two distinct cleanups, and both are startup-only: /// Three distinct cleanups, and all are startup-only:
/// ///
/// - **Reclaim.** A `Running` row has no owner; the process that claimed it is /// - **Reclaim.** A `Running` row has no owner; the process that claimed it is
/// gone. On Android that is a routine morning, not a crash (FR-PLAT-AND-3). /// gone. On Android that is a routine morning, not a crash (FR-PLAT-AND-3).
@@ -205,19 +207,27 @@ impl Recovered {
/// process down with it three times running should not be retried forever, /// process down with it three times running should not be retried forever,
/// and the attempt counter is the only evidence of that we have. /// and the attempt counter is the only evidence of that we have.
/// - **Reap.** Jobs naming an image the catalog no longer has. A library that /// - **Reap.** Jobs naming an image the catalog no longer has. A library that
/// has been culled leaves thumbnail jobs for photographs that were deleted /// has been culled leaves jobs for photographs that were deleted
/// months ago, and every one of them would be claimed, run and failed. /// months ago, and every one of them would be claimed, run and failed.
/// - **Retire.** Rows of a kind nothing enqueues or claims any more
/// ([`jobs::drop_retired`]). Here rather than in a migration so that no
/// schema bump locks an older device out of the synced catalog, and every
/// time rather than once because an older build sharing the catalog will
/// queue them again.
/// ///
/// Reclaim runs first so its count is the honest number of interrupted jobs, /// Retiring runs first, so the other two never touch rows about to go.
/// Reclaim runs next so its count is the honest number of interrupted jobs,
/// before reaping removes whichever of them pointed at nothing. /// before reaping removes whichever of them pointed at nothing.
/// ///
/// **Call this exactly once per catalog, at startup.** It cannot distinguish a /// **Call this exactly once per catalog, at startup.** It cannot distinguish a
/// job a dead process was holding from one a live runner is holding right now, /// job a dead process was holding from one a live runner is holding right now,
/// because there is no owner column — the queue is durable, not distributed. /// because there is no owner column — the queue is durable, not distributed.
pub fn recover(conn: &Connection) -> Result<Recovered, CatalogError> { pub fn recover(conn: &Connection) -> Result<Recovered, CatalogError> {
let retired = jobs::drop_retired(conn)?;
Ok(Recovered { Ok(Recovered {
reclaimed: jobs::recover_orphaned(conn)?, reclaimed: jobs::recover_orphaned(conn)?,
reaped: jobs::reap_orphan_subjects(conn)?, reaped: jobs::reap_orphan_subjects(conn)?,
retired,
}) })
} }
@@ -729,7 +739,7 @@ mod tests {
// which is the window a durable queue exists to survive: no `complete`, // which is the window a durable queue exists to survive: no `complete`,
// no `fail`, just a row marked `Running` with nobody holding it. // no `fail`, just a row marked `Running` with nobody holding it.
let c = db(); let c = db();
queued(&c, JobKind::Thumbnail, 1); queued(&c, JobKind::ContentHash, 1);
// The dead process. It claimed the job and never came back. // The dead process. It claimed the job and never came back.
let claimed = jobs::claim_next(&c, 0).unwrap().expect("claimable"); let claimed = jobs::claim_next(&c, 0).unwrap().expect("claimable");
@@ -738,7 +748,7 @@ mod tests {
// A fresh runner, before it starts, finds the queue empty — the row is // A fresh runner, before it starts, finds the queue empty — the row is
// `Running` and no claim will touch it. // `Running` and no claim will touch it.
let seen = Arc::new(Mutex::new(Vec::new())); let seen = Arc::new(Mutex::new(Vec::new()));
let mut runner = Runner::new(&c).with(recording(vec![JobKind::Thumbnail], seen.clone())); let mut runner = Runner::new(&c).with(recording(vec![JobKind::ContentHash], seen.clone()));
assert_eq!( assert_eq!(
runner.drain_all(0).unwrap().ran(), runner.drain_all(0).unwrap().ran(),
0, 0,
@@ -766,7 +776,7 @@ mod tests {
// the only evidence we keep across a death. Without this a poison-pill // the only evidence we keep across a death. Without this a poison-pill
// job would be reclaimed and re-run forever. // job would be reclaimed and re-run forever.
let c = db(); let c = db();
queued(&c, JobKind::Thumbnail, 1); queued(&c, JobKind::ContentHash, 1);
for _ in 0..MAX_ATTEMPTS { for _ in 0..MAX_ATTEMPTS {
jobs::claim_next(&c, 0).unwrap().expect("claimable"); jobs::claim_next(&c, 0).unwrap().expect("claimable");
@@ -782,13 +792,49 @@ mod tests {
assert_eq!(state, JobState::Failed as i64); assert_eq!(state, JobState::Failed as i64);
} }
#[test]
fn recovery_drops_retired_kinds_every_time_and_nothing_else() {
// What 0.16.0 left behind: a thumbnail job per photograph that nothing
// would ever claim, beside live work that must survive.
let c = db();
queued(&c, JobKind::Thumbnail, 1);
queued(&c, JobKind::Thumbnail, 2);
enqueue(
&c,
JobKind::DetectFaces,
Some(1),
Priority::Background,
None,
)
.unwrap();
let first = recover(&c).unwrap();
assert_eq!(first.retired, 2);
assert!(first.did_anything());
let kinds: Vec<i64> = c
.prepare("SELECT kind FROM jobs")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
assert_eq!(kinds, vec![JobKind::DetectFaces as i64]);
// An older build opening the same catalog queues them again on its
// next scan. The next open by this one clears them again.
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
assert_eq!(recover(&c).unwrap().retired, 1);
assert_eq!(recover(&c).unwrap(), Recovered::default());
}
#[test] #[test]
fn recovery_drops_jobs_whose_photograph_is_gone() { fn recovery_drops_jobs_whose_photograph_is_gone() {
// A culled library leaves thumbnail jobs for images deleted months // A culled library leaves thumbnail jobs for images deleted months
// ago. Every one would be claimed, run and failed. // ago. Every one would be claimed, run and failed.
let c = db(); let c = db();
queued(&c, JobKind::Thumbnail, 1); queued(&c, JobKind::ContentHash, 1);
queued(&c, JobKind::Thumbnail, 2); queued(&c, JobKind::ContentHash, 2);
c.execute("DELETE FROM images WHERE id = 2", []).unwrap(); c.execute("DELETE FROM images WHERE id = 2", []).unwrap();
let recovered = recover(&c).unwrap(); let recovered = recover(&c).unwrap();
@@ -804,7 +850,7 @@ mod tests {
#[test] #[test]
fn a_quiet_startup_recovers_nothing() { fn a_quiet_startup_recovers_nothing() {
let c = db(); let c = db();
queued(&c, JobKind::Thumbnail, 1); queued(&c, JobKind::ContentHash, 1);
assert_eq!(recover(&c).unwrap(), Recovered::default()); assert_eq!(recover(&c).unwrap(), Recovered::default());
assert!(!recover(&c).unwrap().did_anything()); assert!(!recover(&c).unwrap().did_anything());
} }
+5 -2
View File
@@ -301,8 +301,11 @@ pub const EYE_COLUMNS: [&str; 7] = [
/// silently partial — present, queryable, and wrong — which is worse than /// silently partial — present, queryable, and wrong — which is worse than
/// missing, because nothing signals that they need attention. /// missing, because nothing signals that they need attention.
/// ///
/// Cheap enough to run on every open: each pass is one indexed UPDATE, and /// Idempotent: re-running it is a no-op once the values are already right.
/// re-running it is a no-op once the values are already right. /// [`crate::Catalog::open`] runs it once per catalog state rather than on
/// every open — each pass scans a whole table, and together they were most of
/// what an open cost — and `backfilled` in this crate says what counts as a
/// new state.
/// ///
/// Returns how many rows each backfill touched, for logging. /// Returns how many rows each backfill touched, for logging.
pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, CatalogError> { pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, CatalogError> {
+493 -45
View File
@@ -46,18 +46,210 @@ pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> {
Ok(()) Ok(())
} }
/// Write a consistent snapshot of the catalog to `dest`, ready to upload. /// Write a consistent snapshot of the catalog to `dest`, ready to upload,
/// without the face crops.
/// ///
/// Uses the backup API rather than a filesystem copy so the snapshot is /// Built rather than copied. The snapshot is the *whole catalog* bar the
/// coherent even with writers active. Callers should still prefer a quiet /// crops, uploaded on every sync and downloaded by every device. The crops are
/// moment — this competes with background jobs for the write lock. /// most of the file (96 MB of a 158 MB reference catalog), and copying them in
/// only to delete them was most of the cost. A backup-API copy followed by
/// `UPDATE faces SET crop = NULL` and `VACUUM` wrote the file roughly three
/// times over to produce 50 MB (#71). So this creates the schema in an empty
/// file and copies every table into it with `crop` left NULL. That is one
/// pass, with nothing written that is not uploaded.
///
/// Consistency comes from doing the whole copy inside one transaction on the
/// snapshot's connection, which holds a single read snapshot of the source for
/// its duration. A writer committing meanwhile lands in the source's WAL and is
/// simply not seen, the same serialisation the backup API gave.
///
/// Crops are not lost by this: they travel in the face shards
/// ([`crate::face_shard::export_to_shards`]), which are written once and
/// downloaded once. Nothing reads a crop out of a merged remote catalog. The
/// merge reads a remote face's box and model to match it to a local one, and
/// no more. So leaving them out costs a receiving device nothing it would
/// otherwise have had. A device never adopts a downloaded catalog as its own,
/// so a fresh one gets its crops from the shards too.
///
/// The result must stay what every earlier build already merges: same schema,
/// same `user_version`, same page size and the same WAL flag in the header.
/// The `the_snapshot_*` tests pin those.
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> { pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
let out = copy_to(conn, dest)?; // The source is read through a second connection, attached to the
strip_face_crops(&out)?; // snapshot's, so it needs to be a file. Every catalog is one.
let source = conn
.path()
.filter(|p| !p.is_empty())
.map(PathBuf::from)
.ok_or_else(|| CatalogError::Io("the catalog to snapshot has no file".into()))?;
// Not needed for consistency, since the read transaction below sees the
// WAL, but it keeps the live WAL from growing across syncs, as before.
checkpoint(conn)?;
// A leftover from a pass that died mid-build would otherwise be built on.
for stale in [
dest.to_path_buf(),
sidecar_of(dest, "-wal"),
sidecar_of(dest, "-journal"),
sidecar_of(dest, "-shm"),
] {
match std::fs::remove_file(&stale) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(CatalogError::Io(format!("{}: {e}", stale.display()))),
}
}
let out = Connection::open(dest)?;
build_snapshot(conn, &source, &out)?;
// This device's local album folders are paths and SAF grants nobody
// else can use; the merge never reads them, and the snapshot is what
// a fresh device would otherwise adopt whole.
out.execute_batch("DROP TABLE IF EXISTS album_folders")?;
verify_snapshot(&out)?; verify_snapshot(&out)?;
Ok(()) Ok(())
} }
/// `catalog.sqlite` + `-wal` → `catalog.sqlite-wal`.
fn sidecar_of(path: &Path, suffix: &str) -> PathBuf {
let mut s = path.as_os_str().to_owned();
s.push(suffix);
PathBuf::from(s)
}
/// Schema name the source catalog is attached under while a snapshot is built.
const SOURCE_SCHEMA: &str = "snap_src";
/// The body of [`snapshot_for_upload`]: fill the empty database `out` from
/// the catalog at `source`.
fn build_snapshot(conn: &Connection, source: &Path, out: &Connection) -> Result<(), CatalogError> {
// Settings that only take on an empty file, copied from the source so the
// result is the file a backup would have been.
let page_size: i64 = conn.query_row("PRAGMA main.page_size", [], |r| r.get(0))?;
let auto_vacuum: i64 = conn.query_row("PRAGMA main.auto_vacuum", [], |r| r.get(0))?;
out.pragma_update(None, "page_size", page_size)?;
out.pragma_update(None, "auto_vacuum", auto_vacuum)?;
// A scratch file, rebuilt whole on every pass and checked before upload:
// durability during the build buys nothing. MEMORY rather than OFF keeps
// ROLLBACK defined on the failure path.
out.pragma_update(None, "journal_mode", "MEMORY")?;
out.pragma_update(None, "synchronous", "OFF")?;
// The rows were checked when they were written, and the copy has them
// all by the end. The bundled SQLite turns foreign keys on by default,
// and with them a multi-row INSERT into `images` scans `images` for
// children of every row it adds (`shadowed_by` refers to the same table
// and has no index): 1.2 s of a 1.7 s snapshot on 24k images.
out.pragma_update(None, "foreign_keys", false)?;
// Bound as a parameter, so a path containing a quote cannot break out.
out.execute(
&format!("ATTACH DATABASE ?1 AS {SOURCE_SCHEMA}"),
[source.to_string_lossy().as_ref()],
)?;
let result = copy_schema_and_rows(out);
if let Err(e) = out.execute(&format!("DETACH DATABASE {SOURCE_SCHEMA}"), []) {
log::warn!("failed to detach the catalog from its snapshot: {e}");
}
result?;
// Last, and outside any transaction, which is the only place it can be
// set: the header says WAL, as every snapshot uploaded so far has.
out.pragma_update(None, "journal_mode", "WAL")?;
Ok(())
}
fn copy_schema_and_rows(out: &Connection) -> Result<(), CatalogError> {
let tx = out.unchecked_transaction()?;
// The first read of the source opens its read snapshot. Everything from
// here, schema included, is as of that one moment.
let objects: Vec<(String, String, String)> = {
let mut stmt = tx.prepare(&format!(
"SELECT type, name, sql FROM {SOURCE_SCHEMA}.sqlite_master
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'
ORDER BY rowid"
))?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
rows.collect::<Result<_, _>>()?
};
let user_version: i64 =
tx.query_row(&format!("PRAGMA {SOURCE_SCHEMA}.user_version"), [], |r| {
r.get(0)
})?;
let application_id: i64 =
tx.query_row(&format!("PRAGMA {SOURCE_SCHEMA}.application_id"), [], |r| {
r.get(0)
})?;
// Tables and their rows first, then indexes, triggers and views, so that
// an index is built once over the data rather than maintained per row,
// and no trigger fires on the copy. Foreign keys are off on this
// connection, so the order tables are filled in does not matter.
for (_, name, sql) in objects.iter().filter(|(k, _, _)| k == "table") {
// Verbatim: an unqualified CREATE lands in `main`, the snapshot.
tx.execute_batch(sql)?;
let columns: Vec<String> = {
let mut stmt = tx.prepare("SELECT name FROM pragma_table_info(?1, 'main')")?;
let rows = stmt.query_map([name], |r| r.get::<_, String>(0))?;
rows.collect::<Result<_, _>>()?
};
let select = columns
.iter()
.map(|c| {
if name == "faces" && c == "crop" {
"NULL".to_string()
} else {
quote_ident(c)
}
})
.collect::<Vec<_>>()
.join(", ");
let insert = columns
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let table = quote_ident(name);
tx.execute(
&format!(
"INSERT INTO main.{table} ({insert})
SELECT {select} FROM {SOURCE_SCHEMA}.{table}"
),
[],
)?;
}
// AUTOINCREMENT's counters live in a table the filter above skips; the
// CREATE of such a table makes an empty one here.
let has_sequence: bool = tx.query_row(
&format!(
"SELECT EXISTS(SELECT 1 FROM {SOURCE_SCHEMA}.sqlite_master
WHERE name = 'sqlite_sequence')"
),
[],
|r| r.get(0),
)?;
if has_sequence {
tx.execute_batch(&format!(
"DELETE FROM main.sqlite_sequence;
INSERT INTO main.sqlite_sequence SELECT * FROM {SOURCE_SCHEMA}.sqlite_sequence;"
))?;
}
for (_, _, sql) in objects.iter().filter(|(k, _, _)| k != "table") {
tx.execute_batch(sql)?;
}
tx.pragma_update(None, "user_version", user_version)?;
tx.pragma_update(None, "application_id", application_id)?;
tx.commit()?;
Ok(())
}
/// `name` as an SQL identifier, whatever it contains.
fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
/// TRACES: NFR-R2 /// TRACES: NFR-R2
/// Refuse to hand over a snapshot that will not pass `quick_check`. /// Refuse to hand over a snapshot that will not pass `quick_check`.
/// ///
@@ -82,12 +274,12 @@ fn verify_snapshot(snapshot: &Connection) -> Result<(), CatalogError> {
/// Checkpoint, then copy the whole database to `dest`, and hand back the /// Checkpoint, then copy the whole database to `dest`, and hand back the
/// connection to the copy. /// connection to the copy.
/// ///
/// Split out from [`snapshot_for_upload`] because [`crate::recovery`] wants /// What [`crate::recovery`] takes its NFR-R2 backups with. A backup is the
/// exactly this and none of what follows it there: an NFR-R2 backup is the /// file the user may have to *live on*, so it keeps the face crops that
/// file the user may have to *live on*, so it keeps the face crops that an /// [`snapshot_for_upload`] leaves out, and a byte-for-byte page copy is the
/// upload strips. Sharing the copy rather than reimplementing it is what keeps /// right tool. Keeping it here beside the upload keeps the WAL discipline in
/// the WAL discipline in one place — a backup taken with `fs::copy` would be /// one place: a backup taken with `fs::copy` would be the torn snapshot this
/// the torn snapshot this module's header exists to warn about. /// module's header exists to warn about.
pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, CatalogError> { pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, CatalogError> {
checkpoint(conn)?; checkpoint(conn)?;
@@ -103,39 +295,6 @@ pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, Cata
Ok(out) Ok(out)
} }
/// Drop the stored face crops from a snapshot before it is uploaded.
///
/// The snapshot is the *whole catalog*, uploaded on every sync and downloaded
/// by every device. Face crops are a few KB each and a fully indexed library
/// holds tens of thousands of them, so leaving them in would put tens of MB on
/// every round trip — the exact cost `face_shard`'s 25 MB cap exists to bound,
/// and the reason the bulk per-face data lives in shards in the first place.
///
/// Crops are not lost by this: they travel in the face shards
/// ([`crate::face_shard::export_to_shards`]), which are written once and
/// downloaded once. Nothing reads a crop out of a merged remote catalog —
/// [`merge_all`] touches collections and keywords only — so removing them here
/// costs a receiving device nothing it would otherwise have had.
///
/// `VACUUM` afterwards because SQLite does not return freed pages to the file
/// on its own, and an upload sized by the file rather than by its contents
/// would keep paying for bytes that are no longer there.
fn strip_face_crops(snapshot: &Connection) -> Result<(), CatalogError> {
// A catalog older than the crop column is a legitimate input here — a
// snapshot taken mid-migration, or a test fixture built from an earlier
// schema — so an absent column is nothing to fail over.
let has_crop = snapshot
.prepare("SELECT crop FROM faces LIMIT 1")
.map(|_| true)
.unwrap_or(false);
if !has_crop {
return Ok(());
}
snapshot.execute("UPDATE faces SET crop = NULL WHERE crop IS NOT NULL", [])?;
snapshot.execute_batch("VACUUM")?;
Ok(())
}
/// Whether a downloaded remote catalog is worth merging. /// Whether a downloaded remote catalog is worth merging.
/// ///
/// Cheap guard before attaching: a remote written by a newer build may contain /// Cheap guard before attaching: a remote written by a newer build may contain
@@ -171,6 +330,13 @@ pub fn merge_remote(conn: &Connection, remote: &Path) -> Result<MergeReport, Cat
let result = merge::merge_all(conn); let result = merge::merge_all(conn);
// The merge brings in rows the backfill exists for — assignments whose
// word this device has no term for, from a remote older than v6 — so the
// next open must run it, whether or not the stamp happened to move.
if let Some(path) = conn.path().filter(|p| !p.is_empty()) {
crate::backfilled::forget(Path::new(path));
}
// Detach even if the merge failed, or the next attempt errors with // Detach even if the merge failed, or the next attempt errors with
// "database remote_cat is already in use". // "database remote_cat is already in use".
let detach = conn.execute(&format!("DETACH DATABASE {REMOTE_SCHEMA}"), []); let detach = conn.execute(&format!("DETACH DATABASE {REMOTE_SCHEMA}"), []);
@@ -178,6 +344,18 @@ pub fn merge_remote(conn: &Connection, remote: &Path) -> Result<MergeReport, Cat
log::warn!("failed to detach remote catalog: {e}"); log::warn!("failed to detach remote catalog: {e}");
} }
// After every merge, because a merge is where two devices' people meet:
// the same name typed on each, or a redirect one of them made. Its own
// transaction, and a failure is logged rather than returned -- what the
// merge took is committed and valid whether or not the duplicates were
// folded, and the next pass tries again. Runs on the sync worker, never
// the UI thread, and costs ~10 ms when there is nothing to do.
if result.is_ok() {
if let Err(e) = crate::dedup_people::run(conn) {
log::warn!("dedup after the catalog merge: {e}");
}
}
result result
} }
@@ -288,6 +466,92 @@ mod tests {
assert_eq!(n, 2); assert_eq!(n, 2);
} }
/// One image, known to the server by `file_id`, in a catalog.
fn with_image(c: &Connection, file_id: i64) -> dr_types::ImageId {
c.execute(
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
[format!("IMG_{file_id}.CR3")],
)
.unwrap();
let id = c.last_insert_rowid();
c.execute(
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
[id, file_id],
)
.unwrap();
dr_types::ImageId(id as u64)
}
#[test]
fn an_album_and_its_exports_reach_another_device_but_its_folder_does_not() {
use crate::albums::{self, Place};
let dir = tempdir();
let remote_path = dir.join("remote.sqlite");
let snap = dir.join("snap.sqlite");
{
// The desktop: two albums, one on the server and one on its own
// disk, each with an export of the same photograph.
let r = seeded(&dir.join("desktop.sqlite"));
let img = with_image(&r, 4242);
let web = albums::create(&r, "Web", &Place::Server("Shared/Web".into())).unwrap();
let print = albums::create(&r, "Print", &Place::Local("/mnt/print".into())).unwrap();
albums::record_exports(&r, web, &[(img, "IMG_4242.jpg".into())]).unwrap();
albums::record_exports(&r, print, &[(img, "IMG_4242.tif".into())]).unwrap();
snapshot_for_upload(&r, &snap).unwrap();
std::fs::rename(&snap, &remote_path).unwrap();
}
// The tablet knows the same file under its own image id.
let local = seeded(&dir.join("tablet.sqlite"));
with_image(&local, 1);
let img = with_image(&local, 4242);
let report = merge_remote(&local, &remote_path).unwrap();
assert_eq!(report.albums_taken, 2);
assert_eq!(report.album_exports_added, 2);
assert!(report.local_changed());
let all = albums::list(&local).unwrap();
let print = all.iter().find(|a| a.name == "Print").unwrap();
let web = all.iter().find(|a| a.name == "Web").unwrap();
assert_eq!(print.place, None, "the desktop's disk is not the tablet's");
assert_eq!(web.place, Some(Place::Server("Shared/Web".into())));
assert_eq!(albums::sources(&local, web.id).unwrap(), vec![img]);
// Nothing changed on either side, so a second pass takes nothing.
let again = merge_remote(&local, &remote_path).unwrap();
assert_eq!(again.albums_taken, 0);
assert_eq!(again.album_exports_added, 0);
}
#[test]
fn a_device_that_never_made_an_album_still_uploads_this_ones() {
use crate::albums::{self, Place};
let dir = tempdir();
let remote_path = dir.join("remote.sqlite");
{
// A snapshot from a build that predates albums altogether.
let r = seeded(&remote_path);
checkpoint(&r).unwrap();
}
let local = seeded(&dir.join("local.sqlite"));
albums::create(&local, "Web", &Place::Server("Web".into())).unwrap();
let report = merge_remote(&local, &remote_path).unwrap();
assert_eq!(report.albums_taken, 0);
// Its albums table is absent, so nothing was compared — and the local
// album has still to reach the server.
assert!(
albums::list(&local).unwrap().len() == 1,
"the local album survives a merge with a catalog that has none"
);
}
#[test] #[test]
fn the_remote_can_be_merged_twice_without_attach_conflict() { fn the_remote_can_be_merged_twice_without_attach_conflict() {
// Detach must happen even on the failure path, or the second attempt // Detach must happen even on the failure path, or the second attempt
@@ -380,4 +644,188 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(kept, 1, "stripping the snapshot damaged the live catalog"); assert_eq!(kept, 1, "stripping the snapshot damaged the live catalog");
} }
/// Device-side setup for the snapshot tests: an image both devices know by
/// its cross-device file id, and one face on it carrying `crop`.
fn with_a_face(c: &Connection, face_id: i64, x: f64, crop: &[u8]) {
c.execute(
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (1, 1, 'a.CR3', 0)",
[],
)
.unwrap();
c.execute("INSERT INTO remote(image_id, file_id) VALUES (1, 5000)", [])
.unwrap();
c.execute(
"INSERT INTO faces
(id, image_id, x, y, w, h, landmarks, detector_confidence, embedding,
crop_px, model_id, detected_at, crop)
VALUES (?1, 1, ?2, 0.2, 0.2, 0.2, X'00', 0.9, X'00', 150.0, 'w600k_mbf', 0, ?3)",
rusqlite::params![face_id, x, crop],
)
.unwrap();
}
fn crop_of(c: &Connection, face_id: i64) -> Option<Vec<u8>> {
c.query_row("SELECT crop FROM faces WHERE id = ?1", [face_id], |r| {
r.get(0)
})
.unwrap()
}
/// The snapshot is built table by table rather than copied, so what has to
/// hold is that it is still the same database bar the crops: every table,
/// index and row, and the header fields an older build checks before it
/// will merge (`user_version`) or open it the way it always has (the WAL
/// flag and page size a backup-API copy carried).
#[test]
fn the_snapshot_is_the_catalog_bar_the_crops() {
let dir = tempdir();
let live = dir.join("catalog.sqlite");
let snap = dir.join("snap.sqlite");
let c = seeded(&live);
with_a_face(&c, 7, 0.3, &[7u8; 4096]);
c.execute(
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
VALUES ('u1', 'Iceland', 0, 0, 1, 1)",
[],
)
.unwrap();
// Created on first use rather than by a migration: the copy must not
// depend on the migrations knowing every table.
crate::duplicates::ensure_probe_table(&c).unwrap();
snapshot_for_upload(&c, &snap).unwrap();
let out = Connection::open(&snap).unwrap();
let objects = |conn: &Connection| -> Vec<(String, String)> {
let mut stmt = conn
.prepare("SELECT type, name FROM sqlite_master ORDER BY type, name")
.unwrap();
let rows = stmt
.query_map([], |r| Ok((r.get(0).unwrap(), r.get(1).unwrap())))
.unwrap();
rows.map(Result::unwrap).collect()
};
assert_eq!(objects(&out), objects(&c), "the snapshot's schema differs");
for (kind, table) in objects(&c) {
if kind != "table" {
continue;
}
let count = |conn: &Connection| -> i64 {
conn.query_row(&format!("SELECT COUNT(*) FROM \"{table}\""), [], |r| {
r.get(0)
})
.unwrap()
};
assert_eq!(count(&out), count(&c), "rows differ in {table}");
}
for pragma in [
"user_version",
"application_id",
"page_size",
"journal_mode",
] {
let read = |conn: &Connection| -> String {
conn.query_row(&format!("PRAGMA {pragma}"), [], |r| {
r.get::<_, rusqlite::types::Value>(0)
})
.map(|v| format!("{v:?}"))
.unwrap()
};
assert_eq!(read(&out), read(&c), "{pragma} differs");
}
drop(out);
// Bytes 18 and 19 of the header are 2 for a WAL database, which is
// what every snapshot uploaded before this one said.
let header = std::fs::read(&snap).unwrap();
assert_eq!(&header[18..20], &[2, 2], "the snapshot is not WAL-flagged");
// The face is there; its pixels are not.
let out = Connection::open(&snap).unwrap();
assert_eq!(crop_of(&out, 7), None);
}
/// A pass that died mid-build leaves a file behind; the next one must
/// build afresh rather than on top of it.
#[test]
fn a_leftover_snapshot_is_replaced_not_built_on() {
let dir = tempdir();
let live = dir.join("catalog.sqlite");
let snap = dir.join("snap.sqlite");
let c = seeded(&live);
std::fs::write(&snap, b"not a database").unwrap();
snapshot_for_upload(&c, &snap).unwrap();
// And twice over a good one, which is the steady state.
snapshot_for_upload(&c, &snap).unwrap();
let out = Connection::open(&snap).unwrap();
let v: i64 = out
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, schema::SCHEMA_VERSION);
}
/// The merge side of a crop-less snapshot: the other device's names still
/// cross over — the match is by box, not by pixels — and this device's
/// own crop is left exactly as it was, never replaced by the snapshot's
/// NULL.
#[test]
fn merging_a_crop_less_snapshot_keeps_local_crops_and_takes_the_names() {
let dir = tempdir();
let snap = dir.join("snap.sqlite");
let desktop = seeded(&dir.join("desktop.sqlite"));
with_a_face(&desktop, 42, 0.31, &[1u8; 3000]);
desktop
.execute(
"INSERT INTO people(id, uuid, name, ignored, created, revision, modified)
VALUES (3, 'u-anna', 'Anna', 0, 0, 1, 1)",
[],
)
.unwrap();
desktop
.execute(
"INSERT INTO face_person(face_id, person_id, probability, confirmed)
VALUES (42, 3, 0.9, 1)",
[],
)
.unwrap();
snapshot_for_upload(&desktop, &snap).unwrap();
// The tablet found the same face itself, under its own row id, and has
// its own crop of it — from its own detection or from the shards.
let tablet = seeded(&dir.join("tablet.sqlite"));
let mine = vec![9u8; 2500];
with_a_face(&tablet, 7, 0.30, &mine);
let report = merge_remote(&tablet, &snap).unwrap();
assert_eq!(report.people_inserted, 1);
assert_eq!(report.faces_assigned, 1);
let named: (String, bool) = tablet
.query_row(
"SELECT p.name, fp.confirmed
FROM face_person fp JOIN people p ON p.id = fp.person_id
WHERE fp.face_id = 7",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(named, ("Anna".to_string(), true));
assert_eq!(
crop_of(&tablet, 7),
Some(mine),
"the merge touched a local crop"
);
// Idempotent over a crop-less remote too.
let again = merge_remote(&tablet, &snap).unwrap();
assert!(!again.local_changed());
}
} }
+32 -53
View File
@@ -48,7 +48,6 @@ use dr_types::{Availability, FormatFilter, RootId, SourceRef};
use rusqlite::{Connection, OptionalExtension}; use rusqlite::{Connection, OptionalExtension};
use crate::error::CatalogError; use crate::error::CatalogError;
use crate::jobs::{self, JobKind, Priority};
use crate::query::availability_code; use crate::query::availability_code;
use crate::scan::{ use crate::scan::{
classify_dir, classify_entry, DirAction, DirState, EntryAction, KnownFile, ScanOutcome, classify_dir, classify_entry, DirAction, DirState, EntryAction, KnownFile, ScanOutcome,
@@ -336,7 +335,7 @@ pub fn scan_root(
} }
} }
EntryAction::Insert => { EntryAction::Insert => {
let id = insert_image( insert_image(
&tx, &tx,
root, root,
folder_id, folder_id,
@@ -345,11 +344,10 @@ pub fn scan_root(
entry.meta.mtime, entry.meta.mtime,
now, now,
)?; )?;
queue_reading_it(&tx, id)?;
report.inserted += 1; report.inserted += 1;
} }
EntryAction::Changed => { EntryAction::Changed => {
let id = update_image( update_image(
&tx, &tx,
root, root,
folder_id, folder_id,
@@ -357,7 +355,6 @@ pub fn scan_root(
entry.meta.size, entry.meta.size,
entry.meta.mtime, entry.meta.mtime,
)?; )?;
queue_reading_it(&tx, id)?;
report.updated += 1; report.updated += 1;
} }
EntryAction::Ignored => unreachable!("returned above"), EntryAction::Ignored => unreachable!("returned above"),
@@ -640,7 +637,7 @@ fn insert_image(
size: u64, size: u64,
mtime: i64, mtime: i64,
now: i64, now: i64,
) -> Result<i64, CatalogError> { ) -> Result<(), CatalogError> {
// `metadata_state = 1`: the scan knows the name, the size and the mtime, // `metadata_state = 1`: the scan knows the name, the size and the mtime,
// and has read no EXIF. Claiming otherwise would make a date filter // and has read no EXIF. Claiming otherwise would make a date filter
// silently wrong on a freshly scanned library. // silently wrong on a freshly scanned library.
@@ -664,7 +661,7 @@ fn insert_image(
now, now,
], ],
)?; )?;
image_id(conn, root, src) Ok(())
} }
fn update_image( fn update_image(
@@ -674,7 +671,7 @@ fn update_image(
src: &SourceRef, src: &SourceRef,
size: u64, size: u64,
mtime: i64, mtime: i64,
) -> Result<i64, CatalogError> { ) -> Result<(), CatalogError> {
// The content hash is dropped, not recomputed: it described bytes that no // The content hash is dropped, not recomputed: it described bytes that no
// longer exist, and leaving it would let reconnect-by-hash match this image // longer exist, and leaving it would let reconnect-by-hash match this image
// to a file it is no longer a copy of. `metadata_state` goes back to 1 for // to a file it is no longer a copy of. `metadata_state` goes back to 1 for
@@ -692,37 +689,7 @@ fn update_image(
src.key(), src.key(),
], ],
)?; )?;
image_id(conn, root, src) Ok(())
}
fn image_id(conn: &Connection, root: RootId, src: &SourceRef) -> Result<i64, CatalogError> {
Ok(conn.query_row(
"SELECT id FROM images WHERE root_id = ?1 AND source_ref = ?2",
rusqlite::params![root.0 as i64, src.key()],
|r| r.get(0),
)?)
}
/// Queue the work that turns a stat-only row into a usable grid cell.
///
/// Enqueued inside the scan's transaction, so a folder's rows and the jobs that
/// finish them land together — a crash between the two would otherwise leave
/// images no worker was ever told about.
fn queue_reading_it(conn: &Connection, image_id: i64) -> Result<(), CatalogError> {
jobs::enqueue(
conn,
JobKind::ExtractMetadata,
Some(image_id),
Priority::Background,
None,
)?;
jobs::enqueue(
conn,
JobKind::Thumbnail,
Some(image_id),
Priority::Background,
None,
)
} }
/// TRACES: FR-CAT-9 /// TRACES: FR-CAT-9
@@ -831,6 +798,17 @@ mod tests {
let tmp = target.with_extension("tmp"); let tmp = target.with_extension("tmp");
fs::write(&tmp, bytes).expect("write"); fs::write(&tmp, bytes).expect("write");
fs::rename(&tmp, &target).expect("rename"); fs::rename(&tmp, &target).expect("rename");
// A scan tells a changed file by its mtime, at whole-second
// resolution; a resave landing in the same second as the scan
// before it looks unchanged, and the test fails when the machine
// is fast enough. Two seconds ahead, on the file and its folder,
// is what a real resave some time later would look like.
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
for p in [target.as_path(), target.parent().expect("parent")] {
fs::File::open(p)
.and_then(|f| f.set_modified(later))
.expect("set mtime");
}
self self
} }
@@ -1096,7 +1074,7 @@ mod tests {
} }
#[test] #[test]
fn a_resaved_file_is_queued_for_rereading_and_loses_its_stale_hash() { fn a_resaved_file_owes_a_reread_and_loses_its_stale_hash() {
let lib = Library::new("resaved"); let lib = Library::new("resaved");
lib.file("IMG.CR3", b"raw"); lib.file("IMG.CR3", b"raw");
lib.scan(); lib.scan();
@@ -1121,9 +1099,8 @@ mod tests {
"a hash of bytes that no longer exist would match this image to the \ "a hash of bytes that no longer exist would match this image to the \
wrong file on reconnect" wrong file on reconnect"
); );
assert_eq!(lib.count("SELECT metadata_state FROM images"), 1);
assert_eq!( assert_eq!(
lib.count("SELECT count(*) FROM jobs WHERE kind = 1"), lib.count("SELECT metadata_state FROM images"),
1, 1,
"EXIF must be re-read" "EXIF must be re-read"
); );
@@ -1137,7 +1114,11 @@ mod tests {
let lib = Library::new("no-requeue"); let lib = Library::new("no-requeue");
lib.file("a.CR3", b"raw"); lib.file("a.CR3", b"raw");
lib.scan(); lib.scan();
lib.conn().execute("DELETE FROM jobs", []).unwrap(); // As if the metadata sweep had read it: what is owed is recorded in
// `metadata_state`, and the thumbnail store answers for itself.
lib.conn()
.execute("UPDATE images SET metadata_state = 2", [])
.unwrap();
lib.file("b.CR3", b"raw"); lib.file("b.CR3", b"raw");
let r = lib.scan(); let r = lib.scan();
@@ -1145,9 +1126,9 @@ mod tests {
assert_eq!(r.inserted, 1); assert_eq!(r.inserted, 1);
assert_eq!(r.unchanged, 1); assert_eq!(r.unchanged, 1);
assert_eq!( assert_eq!(
lib.count("SELECT count(*) FROM jobs"), lib.count("SELECT count(*) FROM images WHERE metadata_state < 2"),
2, 1,
"EXIF and a thumbnail for the new image, and nothing for the old one" "EXIF owed for the new image, and nothing for the old one"
); );
} }
@@ -1171,17 +1152,15 @@ mod tests {
} }
#[test] #[test]
fn a_new_image_is_queued_for_a_thumbnail_and_for_exif() { fn a_new_image_owes_its_exif_and_queues_nothing() {
// The sweeps find their work from `metadata_state` and the thumbnail
// store. A queued job would be a second record of the same debt, and
// no handler claims one (#73).
let lib = Library::new("queued"); let lib = Library::new("queued");
lib.file("IMG.CR3", b"raw"); lib.file("IMG.CR3", b"raw");
lib.scan(); lib.scan();
assert_eq!( assert_eq!(lib.count("SELECT count(*) FROM jobs"), 0);
lib.count("SELECT count(*) FROM jobs WHERE kind = 2"),
1,
"no thumbnail job means an empty grid cell forever"
);
assert_eq!(lib.count("SELECT count(*) FROM jobs WHERE kind = 1"), 1);
assert_eq!( assert_eq!(
lib.count("SELECT metadata_state FROM images"), lib.count("SELECT metadata_state FROM images"),
1, 1,
@@ -0,0 +1,341 @@
// TRACES: FR-PLAT-AND-3
//! No job kind is enqueued without something that claims it.
//!
//! The queue coalesces, so a producer with no consumer does not fail — it
//! just leaves a row per subject for ever. That is how the reference catalog
//! came to hold 23,582 `Thumbnail` jobs, one per photograph, re-coalesced on
//! every scan, with no handler for the kind anywhere in the tree (#73). Nothing
//! at runtime notices: the rows are cheap one at a time and invisible in the
//! interface. So the pairing is checked here, over the source, instead.
//!
//! ## What counts
//!
//! In shipping code under `core/`, `ui/`, `apps/` and `platform/` — every
//! `src/` tree, with `#[cfg(test)]` items dropped:
//!
//! - **Enqueued**: the `JobKind::X` named in the arguments of a call to
//! `enqueue(`. An enqueue whose kind is not spelled there — passed in a
//! variable — is refused outright, because this scan could not say what it
//! queues.
//! - **Claimed**: the `JobKind::X` in the body of a `fn kinds(` (what a
//! `JobHandler` declares, and all a `Runner` claims), or named in a call to
//! `claim_next_matching(`. A call to `claim_next(` claims every kind.
//! `JobKind::ALL` in either place means every kind.
//!
//! Tests and examples are left out on purpose: a unit test of the queue's
//! mechanics enqueues and claims whatever it likes, and proves nothing about
//! the app.
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
/// Every `.rs` file under each `src/` of each crate in `group`.
fn crate_sources(group: &Path, out: &mut Vec<PathBuf>) {
let Ok(crates) = fs::read_dir(group) else {
return;
};
for krate in crates {
let src = krate.expect("read dir entry").path().join("src");
if src.is_dir() {
rust_files(&src, out);
}
}
}
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()))
{
let path = entry.expect("read dir entry").path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(path);
}
}
}
/// Blank out string literals and line comments, so neither a brace nor a
/// `JobKind::` inside prose is read as code.
fn strip_literals_and_comments(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut chars = line.chars().peekable();
let mut in_string = false;
while let Some(c) = chars.next() {
if in_string {
match c {
'\\' => {
chars.next();
}
'"' => in_string = false,
_ => {}
}
continue;
}
match c {
'"' => in_string = true,
'/' if chars.peek() == Some(&'/') => break,
_ => out.push(c),
}
}
out
}
/// The shipping code of a file, comments and strings blanked, with every
/// `#[cfg(test)]` item dropped. The attribute must be the whole line, so a
/// doc comment mentioning it is not mistaken for one.
fn shipping_code(text: &str) -> String {
let lines: Vec<String> = text.lines().map(strip_literals_and_comments).collect();
let mut out = String::new();
let mut i = 0;
while i < lines.len() {
if lines[i].trim() != "#[cfg(test)]" {
out.push_str(&lines[i]);
out.push('\n');
i += 1;
continue;
}
let (mut j, mut depth, mut opened) = (i + 1, 0i32, false);
while j < lines.len() {
depth += lines[j].matches('{').count() as i32;
depth -= lines[j].matches('}').count() as i32;
opened |= lines[j].contains('{');
if (opened && depth <= 0) || (!opened && lines[j].contains(';')) {
break;
}
j += 1;
}
i = j + 1;
}
out
}
/// The text from `start` (just past an opening delimiter) to its matching
/// close.
fn balanced(code: &str, start: usize, open: char, close: char) -> &str {
let mut depth = 1;
for (i, c) in code[start..].char_indices() {
if c == open {
depth += 1;
} else if c == close {
depth -= 1;
if depth == 0 {
return &code[start..start + i];
}
}
}
&code[start..]
}
/// Each call of `name(` in `code` that is a call rather than the function's
/// own definition or a longer name ending in it, as its argument text.
fn calls<'a>(code: &'a str, name: &str) -> Vec<&'a str> {
let needle = format!("{name}(");
let mut found = Vec::new();
for (at, _) in code.match_indices(&needle) {
let before = &code[..at];
let prev = before.chars().next_back();
if prev.is_some_and(|c| c.is_alphanumeric() || c == '_') {
continue;
}
if before.trim_end().ends_with("fn") {
continue;
}
found.push(balanced(code, at + needle.len(), '(', ')'));
}
found
}
/// Bodies of every `fn kinds(` that has one — a trait declaration ending in
/// `;` has none.
fn kinds_bodies(code: &str) -> Vec<&str> {
let mut found = Vec::new();
for (at, _) in code.match_indices("fn kinds(") {
let rest = &code[at..];
let (Some(brace), semi) = (rest.find('{'), rest.find(';')) else {
continue;
};
if semi.is_some_and(|s| s < brace) {
continue;
}
found.push(balanced(code, at + brace + 1, '{', '}'));
}
found
}
/// The `X` of each `JobKind::X` in `text`.
fn kinds_named(text: &str) -> Vec<String> {
text.match_indices("JobKind::")
.map(|(at, m)| {
text[at + m.len()..]
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect()
})
.collect()
}
#[derive(Default, Debug)]
struct Ledger {
/// Kind → where it is enqueued.
enqueued: Vec<(String, String)>,
/// Enqueue calls whose kind could not be read.
unreadable: Vec<String>,
claimed: BTreeSet<String>,
claims_everything: bool,
}
fn read(files: &[(String, String)]) -> Ledger {
let mut ledger = Ledger::default();
for (name, text) in files {
let code = shipping_code(text);
for args in calls(&code, "enqueue") {
let kinds = kinds_named(args);
if kinds.is_empty() {
ledger
.unreadable
.push(format!("{name}: enqueue({})", args.trim()));
}
for k in kinds {
ledger.enqueued.push((k, name.clone()));
}
}
let claimed = kinds_bodies(&code)
.into_iter()
.chain(calls(&code, "claim_next_matching"));
for text in claimed {
for k in kinds_named(text) {
if k == "ALL" {
ledger.claims_everything = true;
} else {
ledger.claimed.insert(k);
}
}
}
if !calls(&code, "claim_next").is_empty() {
ledger.claims_everything = true;
}
}
ledger
}
#[test]
fn every_kind_enqueued_is_claimed_by_something() {
let repo = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("core/dr-catalog has a grandparent");
let mut paths = Vec::new();
for group in ["core", "ui", "apps", "platform"] {
crate_sources(&repo.join(group), &mut paths);
}
let files: Vec<(String, String)> = paths
.iter()
.map(|p| {
let text = fs::read_to_string(p)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", p.display()));
let name = p.strip_prefix(repo).unwrap_or(p).display().to_string();
(name, text)
})
.collect();
// A scan over nothing passes for the wrong reason. The queue's own file
// and the scan that used to feed it must both have been read, and the
// queue's definitions found in them.
for must in [
"core/dr-catalog/src/jobs.rs",
"core/dr-catalog/src/runner.rs",
"ui/dr-ui/src/library/scan.rs",
] {
assert!(
files.iter().any(|(n, _)| n == must),
"{must} was not scanned — the source walk is wrong, not the code"
);
}
let jobs = &files
.iter()
.find(|(n, _)| n == "core/dr-catalog/src/jobs.rs")
.unwrap()
.1;
assert!(shipping_code(jobs).contains("pub fn enqueue("));
let ledger = read(&files);
assert!(
ledger.unreadable.is_empty(),
"\n\nThese enqueue calls do not name their JobKind, so this test cannot \
check that anything claims it. Spell the kind at the call:\n {}\n",
ledger.unreadable.join("\n ")
);
if ledger.claims_everything {
return;
}
let orphans: Vec<String> = ledger
.enqueued
.iter()
.filter(|(k, _)| !ledger.claimed.contains(k))
.map(|(k, at)| format!("JobKind::{k}, enqueued in {at}"))
.collect();
assert!(
orphans.is_empty(),
"\n\nEnqueued, and claimed by nothing (claimed: {:?}):\n {}\n\n\
A kind nobody claims is a row per subject that stays for ever — the \
queue coalesces, so it never fails, it only grows (#73). Register a \
JobHandler for the kind, or stop enqueueing it and add it to \
JobKind::RETIRED so the rows already queued are dropped.\n",
ledger.claimed,
orphans.join("\n ")
);
}
/// The reader itself, on code whose answer is known — so a parsing bug shows
/// up as this failing, rather than the real check quietly finding nothing.
#[test]
fn the_reader_sees_producers_and_consumers() {
let producer = r#"
use dr_catalog::jobs;
fn persist(tx: &Connection, id: i64) {
// jobs::enqueue(tx, JobKind::ContentHash, ...) in a comment is not a call
let _ = dr_catalog::jobs::enqueue(
tx,
JobKind::Thumbnail,
Some(id),
Priority::Background,
None,
);
jobs::enqueue(tx, kind, Some(id), Priority::Background, None)?;
}
pub fn enqueue(conn: &Connection, kind: JobKind) {}
#[cfg(test)]
mod tests {
fn t() { enqueue(&c, JobKind::FetchOriginal, None, P, None); }
}
"#;
let consumer = r#"
impl JobHandler for Faces {
fn kinds(&self) -> &[JobKind] {
&[JobKind::DetectFaces]
}
fn run(&mut self) {}
}
trait JobHandler { fn kinds(&self) -> &[JobKind]; }
fn pull(c: &Connection) { claim_next_matching(c, 0, &[JobKind::FetchPreview]); }
"#;
let ledger = read(&[
("producer.rs".into(), producer.into()),
("consumer.rs".into(), consumer.into()),
]);
let enqueued: Vec<&str> = ledger.enqueued.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(enqueued, vec!["Thumbnail"]);
assert_eq!(ledger.unreadable.len(), 1, "{:?}", ledger.unreadable);
assert_eq!(
ledger.claimed,
BTreeSet::from(["DetectFaces".to_string(), "FetchPreview".to_string()])
);
assert!(!ledger.claims_everything);
}
-160
View File
@@ -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]
-752
View File
@@ -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());
}
}
-25
View File
@@ -16,14 +16,12 @@
//! second decoder can be put behind them without changing any of them //! second decoder can be put behind them without changing any of them
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out. //! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
pub mod base_curve;
mod decoder; mod decoder;
mod error; mod error;
mod locate; mod locate;
mod preview; mod preview;
pub mod profile; pub mod profile;
pub use base_curve::BaseCurve;
pub use decoder::{default, Decoder, Rawler}; pub use decoder::{default, Decoder, Rawler};
pub use error::DecodeError; pub use error::DecodeError;
pub use locate::{ pub use locate::{
@@ -125,19 +123,6 @@ pub struct RawImage {
/// for the light the frame was shot under; see [`profile::CameraProfile`]. /// for the light the frame was shot under; see [`profile::CameraProfile`].
pub color_matrix: Option<[f32; 9]>, pub color_matrix: Option<[f32; 9]>,
/// TRACES: FR-DEV-3e /// TRACES: FR-DEV-3e
/// The per-body rendering curve, the other half of the camera profile.
///
/// The matrix above decides what the colours *are*; this decides what the
/// picture looks like. Carried on the decoded image rather than looked up
/// downstream because this is the only point in the system that knows
/// which body took the frame, and because it is not an edit: it belongs to
/// the file in the same way the masked-photosite crop does, and must never
/// reach a sidecar (FR-NC-9).
///
/// [`BaseCurve::IDENTITY`] for an unknown body with no default in the
/// database, which renders exactly as this decoder did before profiles
/// existed.
pub base_curve: BaseCurve,
/// The usable region of `data`, excluding masked and border photosites. /// The usable region of `data`, excluding masked and border photosites.
pub crop: CropRect, pub crop: CropRect,
/// TRACES: FR-MRG-3 /// TRACES: FR-MRG-3
@@ -592,15 +577,6 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
profile.as_ref().map(|p| p.xyz_to_cam()).as_ref(), profile.as_ref().map(|p| p.xyz_to_cam()).as_ref(),
); );
// The rendering half of the profile (FR-DEV-3e). rawler's cleaned strings
// are preferred where it has them — they are what the shipped database is
// written against — and the matching folds the variants either way, so a
// DNG naming the same body differently still finds its curve.
let base_curve = base_curve::for_body(
image.camera.clean_make.as_str(),
image.camera.clean_model.as_str(),
);
// TRACES: FR-MRG-3 // TRACES: FR-MRG-3
// A linear DNG — three samples per pixel, no colour filter array — is a // A linear DNG — three samples per pixel, no colour filter array — is a
// composite this application wrote (or any other demosaiced DNG). It // composite this application wrote (or any other demosaiced DNG). It
@@ -683,7 +659,6 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
.unwrap_or(u16::MAX), .unwrap_or(u16::MAX),
wb_coeffs, wb_coeffs,
color_matrix, color_matrix,
base_curve,
samples_per_pixel, samples_per_pixel,
profile, profile,
make: image.camera.clean_make.clone(), make: image.camera.clean_make.clone(),
+6 -4
View File
@@ -6,8 +6,10 @@
//! colour needs two things the file cannot supply on its own: a **matrix** //! colour needs two things the file cannot supply on its own: a **matrix**
//! saying how this sensor's three responses relate to the CIE observer, and a //! saying how this sensor's three responses relate to the CIE observer, and a
//! **rendering** saying what to do with the resulting scene-referred values so //! **rendering** saying what to do with the resulting scene-referred values so
//! that a photograph looks like a photograph. This module supplies the first //! that a photograph looks like a photograph. This module supplies the first.
//! and looks up the second ([`crate::base_curve`]). //! The second is not the body's: since D19 it is the pipeline's view
//! transform (FR-DEV-3j), one for every camera, and the per-body base curves
//! that used to be looked up here are retired.
//! //!
//! # What is extracted, and from where //! # What is extracted, and from where
//! //!
@@ -65,8 +67,8 @@
//! FR-DEV-3e defers full `.dcp` support — `HueSatDeltas` and //! FR-DEV-3e defers full `.dcp` support — `HueSatDeltas` and
//! `ProfileLookTable` — and requires that they arrive as *additions* rather //! `ProfileLookTable` — and requires that they arrive as *additions* rather
//! than as a pipeline reordering. They would: both are lookups applied to a //! than as a pipeline reordering. They would: both are lookups applied to a
//! colour after this matrix and before, or alongside, the base curve, so they //! colour at this matrix, before any edit reaches it, so they extend
//! extend [`CameraProfile`] with more calibration data and extend the shader's //! [`CameraProfile`] with more calibration data and extend the shader's
//! camera-profile stage with more work. Nothing above would move. //! camera-profile stage with more work. Nothing above would move.
use crate::{cam_to_srgb_from, invert3}; use crate::{cam_to_srgb_from, invert3};
+31 -9
View File
@@ -1,9 +1,8 @@
# Film stocks # Film stocks
One file per stock in [`profiles/`](profiles/). Adding a stock is adding a 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 file — no code change, no shader, no new operation — because under the GPLv3
`dr-decode`'s base curves work that way: under the GPLv3 a stock should be a stock should be contributable without a release.
contributable without a release.
## What a profile is ## What a profile is
@@ -41,18 +40,41 @@ 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 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 approximation: the reconstructed scene spectrum is linear in the sRGB
triple, so the integral collapses into nine numbers. triple, so the integral collapses into nine numbers.
2. **Three 1D curves**, log exposure to density, sampled at 256 points. 2. **Three 1D curves**, log exposure to density, sampled at 256 points — one
3. **One 32³ lookup**, density to linear sRGB — dye absorption, the print row per development time the datasheet measures. Push picks between the
through the negative, the paper, the viewing illuminant and the chromatic rows, and interpolating them is exact, because density is linear in push
adaptation, all of which take exactly three numbers in. between two measured processes.
3. **One 32³ lookup**, density to linear sRGB — dye absorption, the viewing
illuminant and the chromatic adaptation, all of which take exactly three
numbers in. A printed negative is two: the film's cube ends at the paper's
log exposure through the negative, the enlarger's exposure is added there,
and the paper's own curve row and cube take it to linear sRGB.
Per pixel that is a matrix multiply, three curve taps and one texture fetch. The stock is the last thing that happens to the picture. It runs in the view
Splitting 2 from 3, rather than baking one LUT over exposure, is measured transform's place (D19): handed linear sRGB, scene-referred, after every other
adjustment and after sharpening and noise reduction, and handing back the
rendering the output transform encodes. So every other slider decides the
exposure the negative receives, and the default tone mapping is not applied
on top.
Per pixel that is a matrix multiply, a handful of curve taps and one texture
fetch — two for a print. 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 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 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 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. in linear sRGB, below one 8-bit code value, and there is a test that says so.
**No slider is baked.** Camera exposure is a gain before the matrix, push
chooses between curve rows, print exposure is the addition between the two
cubes, and format sets the grain; each reaches the shader as a uniform that is
linear in what it does. That is what lets a mask layer hold its own film
settings, and a pixel under several layers take the weighted average of them.
Only the enlarger's filtration is solved at bake time, against the
photograph's exposure — an enlarger has one filtration for the whole print —
so the film's Exposure, set on the whole photograph, is the one slider that
rebakes. The stock and its
paper are the photograph's; a layer has no picker.
## Adding a stock ## Adding a stock
If spektrafilm has it, add its name to `STOCKS` in If spektrafilm has it, add its name to `STOCKS` in
+443 -112
View File
@@ -21,14 +21,16 @@
//! curves and dyes, the viewing illuminant, the adaptation — all of it takes //! curves and dyes, the viewing illuminant, the adaptation — all of it takes
//! three numbers in and gives three numbers out. So it bakes into one small //! three numbers in and gives three numbers out. So it bakes into one small
//! 3D lookup, and the per-pixel cost is a matrix multiply, three curve taps //! 3D lookup, and the per-pixel cost is a matrix multiply, three curve taps
//! and one texture fetch. //! and one texture fetch. A print is two: the film's lookup ends at the
//! paper's log exposure, where the enlarger's exposure is an addition, and
//! the paper's curve and lookup take it from there — see [`Paper`].
//! //!
//! Splitting 2 from 3 rather than baking a single LUT over exposure is //! Splitting 2 from 3 rather than baking a single LUT over exposure is
//! deliberate and measured: the curve carries all of the sharp shape and the //! deliberate and measured: the curve carries all of the sharp shape and the
//! dye mixing is smooth, so putting the curve in the 3D LUT would force it //! dye mixing is smooth, so putting the curve in the 3D LUT would force it
//! three times larger for the same error. //! three times larger for the same error.
use crate::profile::Profile; use crate::profile::{Profile, CURVE_SAMPLES};
use crate::spectrum::{illuminant, Spectrum, Viewing}; use crate::spectrum::{illuminant, Spectrum, Viewing};
use crate::tables::{SPECTRUM, SRGB_BASIS}; use crate::tables::{SPECTRUM, SRGB_BASIS};
@@ -47,7 +49,19 @@ pub const MID_GREY: f32 = 0.184;
/// that on: the error is already under what the output can represent. /// that on: the error is already under what the output can represent.
pub const LUT_SIZE: usize = 32; pub const LUT_SIZE: usize = 32;
/// What to develop, and how. /// TRACES: FR-DEV-3f
/// The most development times a stock may measure: one curve row, and one
/// push station, each. Every stock shipped measures five; the ceiling is what
/// the shader's fixed uniform block can hold.
pub const MAX_CURVE_ROWS: usize = 8;
/// What to develop: the materials, and where the enlarger is balanced.
///
/// **Not how far, and not how bright.** Push, print exposure and camera
/// exposure are [`Settings`], evaluated per pixel against these tables, so
/// that a mask layer can hold its own and a pixel under it can take the
/// weighted average of everyone's (FR-DEV-3f). What is left here is what a
/// photograph has one of.
pub struct Recipe<'a> { pub struct Recipe<'a> {
/// The stock the picture was taken on. /// The stock the picture was taken on.
pub film: &'a Profile, pub film: &'a Profile,
@@ -55,17 +69,15 @@ pub struct Recipe<'a> {
/// what a reversal stock wants and what makes a negative come out orange /// what a reversal stock wants and what makes a negative come out orange
/// and inverted — that being what a negative actually looks like. /// and inverted — that being what a negative actually looks like.
pub print: Option<&'a Profile>, pub print: Option<&'a Profile>,
/// Camera exposure, in stops. /// The camera exposure the enlarger is balanced at, in stops. Ignored
pub exposure_ev: f32, /// without a `print`.
/// Enlarger exposure, in stops. Ignored without a `print`.
pub print_exposure_ev: f32,
/// TRACES: FR-DEV-3f
/// Development, in stops of push. Positive develops longer.
/// ///
/// Ignored by a stock measured at one process, of which there are many — /// The *photograph's* exposure, never a region's. An enlarger has one
/// see [`crate::profile::Profile::curves_at_push`], which returns the one /// filtration for the whole print: a negative exposed a stop brighter in
/// measured curve rather than inventing a pushed one. /// one corner prints a stop darker there, and that difference is the
pub push_stops: f32, /// picture — balancing it away per pixel would erase every local exposure
/// change a layer made.
pub exposure_ev: f32,
} }
impl<'a> Recipe<'a> { impl<'a> Recipe<'a> {
@@ -76,12 +88,27 @@ impl<'a> Recipe<'a> {
film, film,
print, print,
exposure_ev: 0.0, exposure_ev: 0.0,
print_exposure_ev: 0.0,
push_stops: 0.0,
} }
} }
} }
/// TRACES: FR-DEV-3f
/// What a pixel is developed with, against a [`Baked`] stock.
///
/// The shader's uniforms, as the CPU sees them: every field is linear in what
/// the tables are indexed by, which is what lets the composer blend several
/// layers' settings into one before the fragment runs.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Settings {
/// Camera exposure, in stops: a gain on the scene.
pub exposure_ev: f32,
/// Development, in stops of push. Positive develops longer. Nothing for a
/// stock measured at one process, of which there are many.
pub push_stops: f32,
/// Enlarger exposure, in stops. Nothing without a print.
pub print_exposure_ev: f32,
}
/// A recipe reduced to three tables. /// A recipe reduced to three tables.
/// ///
/// Plain `f32` with a documented layout, and no notion of a texture: what to /// Plain `f32` with a documented layout, and no notion of a texture: what to
@@ -89,16 +116,31 @@ impl<'a> Recipe<'a> {
/// the whole model be tested on the CPU. /// the whole model be tested on the CPU.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Baked { pub struct Baked {
/// Linear sRGB to the three layers' log₁₀ exposure, before the log — row /// Linear sRGB to the three layers' exposure, before the log — row `l`,
/// `l`, column `c` is layer `l`'s response to sRGB channel `c`. /// column `c` is layer `l`'s response to sRGB channel `c`. At unit gain:
/// [`Settings::exposure_ev`] is applied per pixel.
pub exposure_matrix: [[f32; 3]; 3], pub exposure_matrix: [[f32; 3]; 3],
/// The characteristic curves, `CURVE_SAMPLES` samples per layer, uniform /// The characteristic curves: `curve_rows` rows of `CURVE_SAMPLES`
/// over `[curve_log_min, curve_log_max]`. /// samples, row after row, each uniform over
/// `[curve_log_min, curve_log_max]`.
///
/// Row `r` is the stock as measured at its `r`th development time, which
/// is push [`Self::push_stations`]`[r]`. The rows are the measurements
/// themselves rather than a resampling: between two, density is linear in
/// push (development is interpolated in log time, and push is log time),
/// so interpolating the rows by push reproduces
/// [`Profile::curves_at_push`] exactly. A stock measured at one process
/// has one row.
pub curves: Vec<[f32; 3]>, pub curves: Vec<[f32; 3]>,
pub curve_rows: usize,
/// The push each row was developed to, ascending, one per row.
pub push_stations: Vec<f32>,
pub curve_log_min: f32, pub curve_log_min: f32,
pub curve_log_max: f32, pub curve_log_max: f32,
/// Density to linear sRGB, `LUT_SIZE³` entries uniform over /// Film density to what comes next, `LUT_SIZE³` entries uniform over
/// `[0, density_max]` on each axis. /// `[0, density_max]` on each axis: linear sRGB when the film is viewed
/// directly, and the paper's log₁₀ exposure through it, per layer, when it
/// is printed.
/// ///
/// **The red axis varies fastest**, then green, then blue — that is, /// **The red axis varies fastest**, then green, then blue — that is,
/// `lut[(b * size + g) * size + r]`. Stated because it is not the order /// `lut[(b * size + g) * size + r]`. Stated because it is not the order
@@ -108,60 +150,142 @@ pub struct Baked {
/// picture with red and blue transposed, which looks like a plausible /// picture with red and blue transposed, which looks like a plausible
/// photograph of the wrong colour. /// photograph of the wrong colour.
pub lut: Vec<[f32; 3]>, pub lut: Vec<[f32; 3]>,
/// The paper, when there is one. See [`Paper`].
pub paper: Option<Paper>,
/// The deepest density any row develops to, so one lookup covers every
/// push.
pub density_max: f32, pub density_max: f32,
pub lut_size: usize, pub lut_size: usize,
} }
/// TRACES: FR-DEV-3f
/// The print half of a baked stock: enlarger to paper to viewing.
///
/// Split from the film's lookup at the paper's log exposure, for the reason
/// the film is split from its own curve. The enlarger's exposure is a shift
/// *in that log exposure*, the same stops on all three layers, so a print
/// exposure is an addition between the two lookups — exact at any value and
/// free per pixel. Baking it into one lookup instead needs a slice per
/// setting, and interpolating between slices misses by several code values,
/// because the paper's curve is the sharpest thing in the print.
#[derive(Debug, Clone)]
pub struct Paper {
/// The enlarger's filtration, per layer, in log₁₀ exposure: what makes a
/// mid-grey scene print neutral at the photograph's exposure. See
/// [`Recipe::exposure_ev`].
pub balance: [f32; 3],
/// The paper's characteristic curves, `CURVE_SAMPLES` samples uniform
/// over `[log_min, log_max]`.
pub curves: Vec<[f32; 3]>,
pub log_min: f32,
pub log_max: f32,
/// Paper density to linear sRGB, laid out as [`Baked::lut`] is, uniform
/// over `[0, density_max]`.
pub lut: Vec<[f32; 3]>,
pub density_max: f32,
}
/// Where `push` falls among the rows: the lower row and the fraction toward
/// the next. Clamped at both ends, as `curves_at_push` clamps to the first and
/// last measured process.
fn push_row(stations: &[f32], push: f32) -> (usize, f32) {
if stations.len() < 2 {
return (0, 0.0);
}
let last = stations.len() - 1;
let hi = stations
.iter()
.position(|p| *p >= push)
.unwrap_or(last)
.max(1);
let lo = hi - 1;
let f = (push - stations[lo]) / (stations[hi] - stations[lo]).max(1e-6);
(lo, f.clamp(0.0, 1.0))
}
impl Baked { impl Baked {
/// Look a colour up the way the shader will, for tests and for previews. /// Look a colour up the way the shader will, at the stock's own settings.
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
self.apply_at(rgb, &Settings::default())
}
/// Look a colour up the way the shader will, for tests and for previews.
pub fn apply_at(&self, rgb: [f32; 3], settings: &Settings) -> [f32; 3] {
let gain = 2f32.powf(settings.exposure_ev);
let mut log_exposure = [0.0f32; 3]; let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() { for (l, slot) in log_exposure.iter_mut().enumerate() {
let m = self.exposure_matrix[l]; let m = self.exposure_matrix[l];
let e = m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]; let e = gain * (m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]);
*slot = (e.max(0.0) + 1e-10).log10(); *slot = (e.max(0.0) + 1e-10).log10();
} }
self.sample_lut(self.sample_curves(log_exposure)) let density = self.sample_curves(log_exposure, settings.push_stops);
let through = sample_cube(&self.lut, self.lut_size, density, self.density_max);
let Some(paper) = &self.paper else {
return through;
};
let shift = settings.print_exposure_ev * 2f32.log10();
let paper_log = [0, 1, 2].map(|l| through[l] + paper.balance[l] + shift);
let paper_density = sample_curve(&paper.curves, paper.log_min, paper.log_max, paper_log);
sample_cube(&paper.lut, self.lut_size, paper_density, paper.density_max)
} }
fn sample_curves(&self, log_exposure: [f32; 3]) -> [f32; 3] { fn sample_curves(&self, log_exposure: [f32; 3], push_stops: f32) -> [f32; 3] {
let last = self.curves.len() - 1; let (row, g) = push_row(&self.push_stations, push_stops);
let span = self.curve_log_max - self.curve_log_min; let lo = self.sample_curve_row(log_exposure, row);
let mut out = [0.0f32; 3]; if self.curve_rows < 2 {
for (c, slot) in out.iter_mut().enumerate() { return lo;
let t = ((log_exposure[c] - self.curve_log_min) / span).clamp(0.0, 1.0) * last as f32;
let i = (t.floor() as usize).min(last - 1);
let f = t - i as f32;
*slot = self.curves[i][c] * (1.0 - f) + self.curves[i + 1][c] * f;
} }
out let hi = self.sample_curve_row(log_exposure, row + 1);
[0, 1, 2].map(|c| lo[c] * (1.0 - g) + hi[c] * g)
} }
fn sample_lut(&self, density: [f32; 3]) -> [f32; 3] { fn sample_curve_row(&self, log_exposure: [f32; 3], row: usize) -> [f32; 3] {
let n = self.lut_size; let samples = self.curves.len() / self.curve_rows;
let mut base = [0usize; 3]; let curve = &self.curves[row * samples..(row + 1) * samples];
let mut frac = [0f32; 3]; sample_curve(curve, self.curve_log_min, self.curve_log_max, log_exposure)
for c in 0..3 { }
let t = (density[c] / self.density_max).clamp(0.0, 1.0) * (n - 1) as f32; }
base[c] = (t.floor() as usize).min(n - 2);
frac[c] = t - base[c] as f32; /// Three curves sampled uniformly over `[log_min, log_max]`, read at a log
} /// exposure per layer. Clamped at both ends, as the shader's is.
let mut out = [0.0f32; 3]; fn sample_curve(curve: &[[f32; 3]], log_min: f32, log_max: f32, at: [f32; 3]) -> [f32; 3] {
for dx in 0..2 { let last = curve.len() - 1;
for dy in 0..2 { let span = log_max - log_min;
for dz in 0..2 { let mut out = [0.0f32; 3];
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] } for (c, slot) in out.iter_mut().enumerate() {
* if dy == 0 { 1.0 - frac[1] } else { frac[1] } let t = ((at[c] - log_min) / span).clamp(0.0, 1.0) * last as f32;
* if dz == 0 { 1.0 - frac[2] } else { frac[2] }; let i = (t.floor() as usize).min(last - 1);
let e = self.lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx]; let f = t - i as f32;
for c in 0..3 { *slot = curve[i][c] * (1.0 - f) + curve[i + 1][c] * f;
out[c] += w * e[c]; }
} out
}
/// A cube of `n³` triples over `[0, max]` per axis, red fastest, read
/// trilinearly.
fn sample_cube(lut: &[[f32; 3]], n: usize, density: [f32; 3], max: f32) -> [f32; 3] {
let mut base = [0usize; 3];
let mut frac = [0f32; 3];
for c in 0..3 {
let t = (density[c] / max).clamp(0.0, 1.0) * (n - 1) as f32;
base[c] = (t.floor() as usize).min(n - 2);
frac[c] = t - base[c] as f32;
}
let mut out = [0.0f32; 3];
for dx in 0..2 {
for dy in 0..2 {
for dz in 0..2 {
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] }
* if dy == 0 { 1.0 - frac[1] } else { frac[1] }
* if dz == 0 { 1.0 - frac[2] } else { frac[2] };
let e = lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx];
for c in 0..3 {
out[c] += w * e[c];
} }
} }
} }
out
} }
out
} }
/// Linear sRGB to the three layers' exposure, mid-grey normalised. /// Linear sRGB to the three layers' exposure, mid-grey normalised.
@@ -204,12 +328,7 @@ pub fn exposure_matrix(film: &Profile) -> [[f32; 3]; 3] {
/// goes: the mask is a fixed density, so balancing mid-grey to neutral cancels /// goes: the mask is a fixed density, so balancing mid-grey to neutral cancels
/// it — which is why a printed negative looks like a photograph while a scanned /// it — which is why a printed negative looks like a photograph while a scanned
/// one looks orange. /// one looks orange.
fn print_balance( pub fn print_balance(film: &Profile, paper: &Profile, exposure_ev: f32) -> [f32; 3] {
film: &Profile,
paper: &Profile,
exposure_ev: f32,
print_exposure_ev: f32,
) -> [f32; 3] {
let matrix = exposure_matrix(film); let matrix = exposure_matrix(film);
let scene = MID_GREY * 2f32.powf(exposure_ev); let scene = MID_GREY * 2f32.powf(exposure_ev);
let mut log_exposure = [0.0f32; 3]; let mut log_exposure = [0.0f32; 3];
@@ -227,7 +346,7 @@ fn print_balance(
let mut offsets = [0.0f32; 3]; let mut offsets = [0.0f32; 3];
for (l, slot) in offsets.iter_mut().enumerate() { for (l, slot) in offsets.iter_mut().enumerate() {
*slot = target - (mid_raw[l] + 1e-10).log10() + print_exposure_ev * 2f32.log10(); *slot = target - (mid_raw[l] + 1e-10).log10();
} }
offsets offsets
} }
@@ -257,77 +376,117 @@ fn paper_exposure(film: &Profile, paper: &Profile, density: [f32; 3]) -> [f32; 3
/// Bake a recipe into the tables a shader runs. /// Bake a recipe into the tables a shader runs.
pub fn bake(recipe: &Recipe) -> Baked { pub fn bake(recipe: &Recipe) -> Baked {
let film = recipe.film; let film = recipe.film;
let mut matrix = exposure_matrix(film); // At unit gain. Camera exposure is a scalar on a linear quantity, so the
// Camera exposure rides in the matrix rather than in the shader: it is a // shader applies it for the price of one multiply — and has to, since a
// scalar on a linear quantity, and folding it in here costs nothing and // layer may hold its own.
// keeps the per-pixel work identical whether or not it has been moved. let matrix = exposure_matrix(film);
let gain = 2f32.powf(recipe.exposure_ev);
for row in &mut matrix {
for v in row.iter_mut() {
*v *= gain;
}
}
// TRACES: FR-DEV-3f // TRACES: FR-DEV-3f
// Developed to the requested push before anything else reads the curves: // Every measured process, not the one the slider is at: the shader
// the density ceiling, the print balance and the grain all depend on how // interpolates between rows per pixel, so a layer can push a region.
// far this film was taken, and a push that only reached one of them would // Resampled to one length because the rows share a texture.
// be a contrast change wearing a push's name. let measured = film.development_curves.len() >= 2
let curves = film.curves_at_push(recipe.push_stops); && film.development_times.len() == film.development_curves.len();
let density_max = curves let (curves, push_stations): (Vec<[f32; 3]>, Vec<f32>) = if measured {
.iter() let rows = film.development_curves.len().min(MAX_CURVE_ROWS);
.flat_map(|row| row.iter()) (
.fold(0.0f32, |a, &b| a.max(b)) film.development_curves[..rows]
.max(1e-3); .iter()
.flat_map(|c| resample(c))
let viewing = match recipe.print { .collect(),
Some(paper) => Viewing::new(&paper.viewing_illuminant), film.development_times[..rows]
None => Viewing::new(&film.viewing_illuminant), .iter()
.map(|t| 2.0 * (t / film.development_normal).log2())
.collect(),
)
} else {
(resample(&film.density_curves), vec![0.0])
}; };
let balance = recipe let curve_rows = push_stations.len();
.print // The ceiling of the deepest row, so one lookup covers every push.
.map(|paper| print_balance(film, paper, recipe.exposure_ev, recipe.print_exposure_ev)); let density_max = ceiling(&curves);
let n = LUT_SIZE; let n = LUT_SIZE;
let mut lut = Vec::with_capacity(n * n * n);
// Blue outermost and red innermost, so the red axis varies fastest. See // Blue outermost and red innermost, so the red axis varies fastest. See
// `Baked::lut`: this is the layout a 3D texture upload wants, and getting // `Baked::lut`: this is the layout a 3D texture upload wants, and getting
// it backwards transposes red and blue in the finished picture. // it backwards transposes red and blue in the finished picture.
for b in 0..n { let cube = |max: f32, f: &dyn Fn([f32; 3]) -> [f32; 3]| {
for g in 0..n { let mut out = Vec::with_capacity(n * n * n);
for r in 0..n { for b in 0..n {
let density = [ for g in 0..n {
density_max * r as f32 / (n - 1) as f32, for r in 0..n {
density_max * g as f32 / (n - 1) as f32, let step = max / (n - 1) as f32;
density_max * b as f32 / (n - 1) as f32, out.push(f([r as f32 * step, g as f32 * step, b as f32 * step]));
]; }
lut.push(match recipe.print.zip(balance) {
Some((paper, offsets)) => {
let raw = paper_exposure(film, paper, density);
let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() {
*slot = (raw[l] + 1e-10).log10() + offsets[l];
}
let paper_density = paper.density_at(log_exposure);
viewing.to_srgb(&paper.transmittance(paper_density))
}
None => viewing.to_srgb(&film.transmittance(density)),
});
} }
} }
} out
};
let (lut, paper) = match recipe.print {
None => {
let viewing = Viewing::new(&film.viewing_illuminant);
(
cube(density_max, &|d| viewing.to_srgb(&film.transmittance(d))),
None,
)
}
Some(paper) => {
let viewing = Viewing::new(&paper.viewing_illuminant);
let curves = resample(&paper.density_curves);
let paper_max = ceiling(&curves);
let lut = cube(density_max, &|d| {
paper_exposure(film, paper, d).map(|raw| (raw + 1e-10).log10())
});
let paper = Paper {
balance: print_balance(film, paper, recipe.exposure_ev),
log_min: paper.log_exposure_min,
log_max: paper.log_exposure_max,
lut: cube(paper_max, &|d| viewing.to_srgb(&paper.transmittance(d))),
density_max: paper_max,
curves,
};
(lut, Some(paper))
}
};
Baked { Baked {
exposure_matrix: matrix, exposure_matrix: matrix,
curves, curves,
curve_rows,
push_stations,
curve_log_min: film.log_exposure_min, curve_log_min: film.log_exposure_min,
curve_log_max: film.log_exposure_max, curve_log_max: film.log_exposure_max,
lut, lut,
paper,
density_max, density_max,
lut_size: n, lut_size: n,
} }
} }
/// A curve at `CURVE_SAMPLES`, uniform over the same domain it came in on.
fn resample(curve: &[[f32; 3]]) -> Vec<[f32; 3]> {
if curve.len() == CURVE_SAMPLES {
return curve.to_vec();
}
(0..CURVE_SAMPLES)
.map(|i| {
let at = i as f32 / (CURVE_SAMPLES - 1) as f32;
sample_curve(curve, 0.0, 1.0, [at; 3])
})
.collect()
}
/// The deepest density in a set of curves, floored so a lookup over it has
/// a width.
fn ceiling(curves: &[[f32; 3]]) -> f32 {
curves
.iter()
.flat_map(|row| row.iter())
.fold(0.0f32, |a, &b| a.max(b))
.max(1e-3)
}
fn mean(s: &Spectrum) -> f32 { fn mean(s: &Spectrum) -> f32 {
s.iter().sum::<f32>() / SPECTRUM as f32 s.iter().sum::<f32>() / SPECTRUM as f32
} }
@@ -467,6 +626,10 @@ mod tests {
#[test] #[test]
fn exposure_moves_the_print_the_way_it_moves_a_photograph() { fn exposure_moves_the_print_the_way_it_moves_a_photograph() {
// The photograph's exposure: the enlarger balanced at it, and the
// scene brighter by it. Mid-grey stays where the balance puts it —
// that is what the balance is for — so what a stop more does to a
// print is lift everything either side of it along the paper's curve.
let film = portra(); let film = portra();
let paper = endura(); let paper = endura();
let brighter = bake(&Recipe { let brighter = bake(&Recipe {
@@ -474,7 +637,155 @@ mod tests {
..Recipe::new(&film, Some(&paper)) ..Recipe::new(&film, Some(&paper))
}); });
let base = bake(&Recipe::new(&film, Some(&paper))); let base = bake(&Recipe::new(&film, Some(&paper)));
assert!(brighter.apply([MID_GREY; 3])[1] > base.apply([MID_GREY; 3])[1]); let one_stop = Settings {
exposure_ev: 1.0,
..Settings::default()
};
for v in [0.02f32, 0.6] {
assert!(
brighter.apply_at([v; 3], &one_stop)[1] > base.apply([v; 3])[1],
"{v} did not print brighter a stop up"
);
}
let (a, b) = (
brighter.apply_at([MID_GREY; 3], &one_stop)[1],
base.apply([MID_GREY; 3])[1],
);
assert!(
(a - b).abs() < 1.0 / 255.0,
"the balance let mid-grey move: {a} vs {b}"
);
}
#[test]
fn a_region_exposed_brighter_prints_brighter_than_the_enlarger_expects() {
// TRACES: FR-DEV-3f
// A layer's exposure is the scene's, not the enlarger's: the balance
// stays where the photograph put it, so the region prints lighter by
// more than the whole photograph would, which is what dodging at the
// camera is.
let film = portra();
let paper = endura();
let base = bake(&Recipe::new(&film, Some(&paper)));
let rebalanced = bake(&Recipe {
exposure_ev: 1.0,
..Recipe::new(&film, Some(&paper))
});
let one_stop = Settings {
exposure_ev: 1.0,
..Settings::default()
};
let local = base.apply_at([MID_GREY; 3], &one_stop)[1];
let global = rebalanced.apply_at([MID_GREY; 3], &one_stop)[1];
assert!(local > base.apply([MID_GREY; 3])[1], "not brighter at all");
assert!(
local > global,
"a region was rebalanced as though it were the whole print: {local} vs {global}"
);
}
#[test]
fn more_light_through_the_enlarger_darkens_the_print() {
// TRACES: FR-DEV-3f
// Paper is negative-working. Opening the enlarger a stop is burning
// in, and a slider that brightened would be the wrong way round for
// anyone who has printed.
let film = portra();
let paper = endura();
let baked = bake(&Recipe::new(&film, Some(&paper)));
let at = |stops: f32| {
baked.apply_at(
[MID_GREY; 3],
&Settings {
print_exposure_ev: stops,
..Settings::default()
},
)[1]
};
assert!(at(1.0) < at(0.0) && at(0.0) < at(-1.0));
}
#[test]
fn a_push_on_a_row_is_the_measured_curve() {
// TRACES: FR-DEV-3f
// The rows are the measured processes, so at a row the table must be
// that curve exactly, and between rows — density being linear in push
// there — it must be `curves_at_push` to rounding.
let film = profile(include_str!("../profiles/kodak_doublex.yaml"));
let baked = bake(&Recipe::new(&film, None));
assert_eq!(
baked.curve_rows, 5,
"Double-X measures five development times"
);
let span = film.log_exposure_max - film.log_exposure_min;
let mut worst = 0.0f32;
let stations = baked.push_stations.clone();
let mut pushes: Vec<(f32, bool)> = stations.iter().map(|p| (*p, true)).collect();
for k in 0..=16 {
pushes.push((-1.0 + 4.0 * k as f32 / 16.0, false));
}
for (push, on_row) in pushes {
let exact = film.curves_at_push(push);
for i in (0..exact.len()).step_by(7) {
let log = film.log_exposure_min + span * i as f32 / (exact.len() - 1) as f32;
let got = baked.sample_curves([log; 3], push);
for c in 0..3 {
let err = (got[c] - exact[i][c]).abs();
if on_row {
assert!(err < 1e-4, "push {push} is a row but misses it by {err}");
}
worst = worst.max(err);
}
}
}
assert!(worst < 1e-3, "between rows the density is off by {worst}");
}
#[test]
fn a_print_exposure_is_exact_at_any_setting() {
// TRACES: FR-DEV-3f
// The enlarger's exposure is added between the two lookups rather than
// baked into either, so no setting is nearer the tables than another.
// Compared against the chain evaluated spectrally, end to end, at
// settings chosen off every half and whole stop.
let film = portra();
let paper = endura();
let baked = bake(&Recipe::new(&film, Some(&paper)));
let offsets = print_balance(&film, &paper, 0.0);
let viewing = Viewing::new(&paper.viewing_illuminant);
let mut worst = 0.0f32;
for stops in [-2.3f32, -0.6, 0.0, 0.35, 1.7] {
for i in 0..14 {
let v = 0.004 * 2f32.powf(i as f32 * 0.6);
let rgb = [v, v * 0.8, v * 1.1];
let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() {
let m = baked.exposure_matrix[l];
*slot =
((m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]).max(0.0) + 1e-10).log10();
}
let raw = paper_exposure(&film, &paper, film.density_at(log_exposure));
let paper_log =
[0, 1, 2].map(|l| (raw[l] + 1e-10).log10() + offsets[l] + stops * 2f32.log10());
let exact = viewing.to_srgb(&paper.transmittance(paper.density_at(paper_log)));
let approx = baked.apply_at(
rgb,
&Settings {
print_exposure_ev: stops,
..Settings::default()
},
);
for c in 0..3 {
worst = worst.max((exact[c] - approx[c]).abs());
}
}
}
assert!(
worst < 1.0 / 255.0,
"the print misses the spectral chain by {worst}"
);
} }
#[test] #[test]
@@ -550,5 +861,25 @@ mod tests {
let baked = bake(&Recipe::new(&film, None)); let baked = bake(&Recipe::new(&film, None));
assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE); assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE);
assert_eq!(baked.curves.len(), CURVE_SAMPLES); assert_eq!(baked.curves.len(), CURVE_SAMPLES);
assert_eq!(baked.curve_rows, 1);
assert!(baked.paper.is_none());
// A print has a second lookup and a curve of its own; a development
// series a row per push. Neither is inferred from the other.
let negative = portra();
let paper = endura();
let printed = bake(&Recipe::new(&negative, Some(&paper)));
let print = printed
.paper
.as_ref()
.expect("a printed negative has a paper");
assert_eq!(print.lut.len(), LUT_SIZE.pow(3));
assert_eq!(print.curves.len(), CURVE_SAMPLES);
let pushable = profile(include_str!("../profiles/kodak_doublex.yaml"));
let rows = bake(&Recipe::new(&pushable, None));
assert_eq!(rows.curve_rows, pushable.development_times.len());
assert_eq!(rows.push_stations.len(), rows.curve_rows);
assert_eq!(rows.curves.len(), rows.curve_rows * CURVE_SAMPLES);
} }
} }
+3 -3
View File
@@ -2,9 +2,9 @@
//! //!
//! # Why it is data //! # Why it is data
//! //!
//! The same argument `dr_decode::base_curve` makes for camera bodies, and for //! Under the GPLv3 a stock should be contributable without a release (the
//! the same requirement: under the GPLv3 a stock should be contributable //! argument the retired per-body base curves made for camera bodies, before
//! without a release. A profile is three tables and a handful of facts, all of //! D19). A profile is three tables and a handful of facts, all of
//! them published in the manufacturer's datasheet, so adding a stock is adding //! them published in the manufacturer's datasheet, so adding a stock is adding
//! a file — not a code change, not a shader, and not a new operation. //! a file — not a code change, not a shader, and not a new operation.
//! //!
+4 -1
View File
@@ -680,15 +680,18 @@ fn film_tables() -> FilmTables {
FilmTables { FilmTables {
exposure_matrix: baked.exposure_matrix, exposure_matrix: baked.exposure_matrix,
curves: baked.curves.clone(), curves: baked.curves.clone(),
push_stations: baked.push_stations.clone(),
curve_log_min: baked.curve_log_min, curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max, curve_log_max: baked.curve_log_max,
lut: baked.lut.clone(), lut: baked.lut.clone(),
density_max: baked.density_max, density_max: baked.density_max,
lut_size: baked.lut_size, lut_size: baked.lut_size,
// Viewed directly: `STOCK` is baked without a paper above.
paper: None,
// Grain off. It is a per-pixel hash and would be measured; it is also // Grain off. It is a per-pixel hash and would be measured; it is also
// not part of every edit, and the chain being measured here is "every // not part of every edit, and the chain being measured here is "every
// operation active", not "every option of every operation". // operation active", not "every option of every operation".
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; dr_pipeline::ops::film_sim::FORMAT_COUNT],
grain_density_max: [baked.density_max; 3], grain_density_max: [baked.density_max; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
} }
+1 -2
View File
@@ -182,8 +182,7 @@ fn render_to(
// and the example never has to know which it was handed. // and the example never has to know which it was handed.
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(image.size(), (w, h)); let scale = graph.render_scale(image.size(), (w, h));
let detail = let detail = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour); let key = graph.invalidation().through(Affects::Colour);
adjust adjust
.render_detailed(image, &shader, w, h, None, &detail, key) .render_detailed(image, &shader, w, h, None, &detail, key)
+199 -80
View File
@@ -34,16 +34,6 @@ use crate::{DemosaicedImage, GpuContext, GpuError};
/// reads them. /// reads them.
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS; const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
/// TRACES: FR-DEV-3e
/// The two crates must agree on how many points a base curve has.
///
/// `dr-decode` reads them from the profile database and `dr-pipeline` declares
/// the uniform slots; this file is the only place the two meet, and it packs
/// them by index. A disagreement would not fail to compile — it would upload a
/// curve with a point missing or a stale float in it, which renders as a
/// plausible-looking wrong tone response. Cheaper to catch here, at build time.
const _: () = assert!(dr_decode::base_curve::POINTS == dr_pipeline::BASE_CURVE_POINTS);
/// Runs composed operation chains against demosaiced images. /// Runs composed operation chains against demosaiced images.
pub struct AdjustPass { pub struct AdjustPass {
ctx: GpuContext, ctx: GpuContext,
@@ -132,6 +122,8 @@ pub struct AdjustPass {
colour_dispatches: usize, colour_dispatches: usize,
/// Detail dispatches encoded. /// Detail dispatches encoded.
detail_dispatches: usize, detail_dispatches: usize,
/// View passes encoded — one per render with a detail stage (D19).
view_dispatches: usize,
} }
struct Target { struct Target {
@@ -382,9 +374,23 @@ fn film_key(t: &dr_pipeline::ops::FilmTables) -> u64 {
t.curve_log_max, t.curve_log_max,
t.density_max, t.density_max,
t.lut_size as f32, t.lut_size as f32,
t.curves.len() as f32,
t.lut.len() as f32,
] { ] {
mix(v.to_bits()); mix(v.to_bits());
} }
for v in &t.push_stations {
mix(v.to_bits());
}
// The paper's balance is left out on purpose: it reaches the shader as
// uniforms, not texels, and it is what moves when the photograph's
// exposure does — keying on it would re-upload a megabyte per tick of a
// slider that changes three floats.
if let Some(p) = &t.paper {
for v in [p.log_min, p.log_max, p.density_max] {
mix(v.to_bits());
}
}
for e in t.lut.iter().step_by(8).chain(t.curves.iter().step_by(8)) { for e in t.lut.iter().step_by(8).chain(t.curves.iter().step_by(8)) {
mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22)); mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22));
} }
@@ -431,12 +437,16 @@ impl AdjustPass {
return; return;
} }
// One row per curve — the film's at each measured push, then the
// paper's — and one cube per stage stacked in depth. The shader reads
// the layout from `FilmTables`' uniforms, not from these sizes.
let samples = dr_pipeline::ops::film_sim::CURVE_SAMPLES as u32;
let curves = self.upload_film( let curves = self.upload_film(
"adjust-film-curves", "adjust-film-curves",
wgpu::TextureDimension::D2, wgpu::TextureDimension::D2,
wgpu::Extent3d { wgpu::Extent3d {
width: t.curves.len() as u32, width: samples,
height: 1, height: t.curves.len() as u32 / samples,
depth_or_array_layers: 1, depth_or_array_layers: 1,
}, },
&to_rgba(&t.curves), &to_rgba(&t.curves),
@@ -448,7 +458,7 @@ impl AdjustPass {
wgpu::Extent3d { wgpu::Extent3d {
width: n, width: n,
height: n, height: n,
depth_or_array_layers: n, depth_or_array_layers: t.lut.len() as u32 / (n * n),
}, },
&to_rgba(&t.lut), &to_rgba(&t.lut),
); );
@@ -645,6 +655,7 @@ impl AdjustPass {
sample: SampleCache::new(ctx), sample: SampleCache::new(ctx),
colour_dispatches: 0, colour_dispatches: 0,
detail_dispatches: 0, detail_dispatches: 0,
view_dispatches: 0,
} }
} }
@@ -1040,13 +1051,14 @@ impl AdjustPass {
/// Render one frame with a neighbourhood stage. /// Render one frame with a neighbourhood stage.
/// ///
/// `shader` and `detail` must be the two halves of **one** composition — /// `shader` and `detail` must be the two halves of **one** composition —
/// `EditGraph::compose_for` and `EditGraph::compose_detail_for` on the same /// `EditGraph::compose_for` and `EditGraph::compose_detail` on the same
/// graph, at the same output space. The fused pass stops at linear working /// graph. The fused pass stops at linear working values when a detail
/// values when a detail stage exists and the last detail pass performs the /// stage exists, and its view pass ([`ComposedShader::view`]) performs the
/// output transform, so a mismatched pair either encodes twice or not at /// view transform and the output transform after the last detail pass
/// all. /// (D19), so a mismatched pair either encodes twice or not at all.
/// ///
/// An empty `detail` falls through to [`Self::render_masked`], which is /// An encoded shader with an empty `detail` falls through to
/// [`Self::render_masked`], which is
/// the honest thing to do rather than an optimisation: an edit with no /// the honest thing to do rather than an optimisation: an edit with no
/// active sharpening *is* an ordinary edit, and it should cost exactly /// active sharpening *is* an ordinary edit, and it should cost exactly
/// what one costs. /// what one costs.
@@ -1086,17 +1098,25 @@ impl AdjustPass {
detail: &ComposedDetail, detail: &ComposedDetail,
colour_key: u64, colour_key: u64,
) -> Result<&wgpu::Texture, GpuError> { ) -> Result<&wgpu::Texture, GpuError> {
if detail.is_empty() { // An edit with no detail stage encodes in the fused pass, and is an
// ordinary render. Decided from the shader rather than from the chain:
// an active kernel too fine for this render emits no pass, and its
// fused pass has still stopped at linear values for the view pass.
if shader.output_mode == OutputMode::Encoded && detail.is_empty() {
return self.render_masked(source, shader, width, height, masks); return self.render_masked(source, shader, width, height, masks);
} }
if shader.output_mode != OutputMode::LinearWorking { let view = match (shader.output_mode, shader.view.as_deref()) {
return Err(GpuError::ShaderCompilation( (OutputMode::LinearWorking, Some(view)) => view,
"this detail chain expects a fused pass composed to hand on \ _ => {
linear working values, but the shader given encodes its own \ return Err(GpuError::ShaderCompilation(
output; compose both halves from the same graph" "this detail chain expects a fused pass composed to hand on \
.into(), linear working values, with its view pass, but the shader \
)); given encodes its own output; compose both halves from the \
} same graph"
.into(),
));
}
};
let (width, height) = (width.max(1), height.max(1)); let (width, height) = (width.max(1), height.max(1));
self.ensure_target(width, height); self.ensure_target(width, height);
@@ -1109,6 +1129,7 @@ impl AdjustPass {
// both want `&mut self`, and the second holds its borrow across the // both want `&mut self`, and the second holds its borrow across the
// encode below. // encode below.
self.pipeline(shader)?; self.pipeline(shader)?;
self.pipeline(view)?;
let colour_view = self let colour_view = self
.detail .detail
.colour_target(detail.len(), width, height) .colour_target(detail.len(), width, height)
@@ -1199,20 +1220,12 @@ impl AdjustPass {
self.colour_dispatches += 1; self.colour_dispatches += 1;
} }
// One encoder for the colour pass and every detail pass, submitted // One encoder for the colour pass, every detail pass and the view pass,
// once — the shape `MaskPass::render` established. Submission order is // submitted once — the shape `MaskPass::render` established.
// the whole of the synchronisation: each pass reads what the previous // Submission order is the whole of the synchronisation: each pass
// one wrote, through the same queue. // reads what the previous one wrote, through the same queue.
let target_view = self.targets[self.current] let (ran, result) = match self.detail.encode(&mut enc, detail, width, height) {
.as_ref() Ok(done) => done,
.expect("ensured above")
.view
.clone();
let ran = match self
.detail
.encode(&mut enc, detail, &target_view, width, height)
{
Ok(ran) => ran,
Err(e) => { Err(e) => {
// Nothing is submitted, so a cache this frame was to write // Nothing is submitted, so a cache this frame was to write
// holds nothing, and must not be read as though it did. // holds nothing, and must not be read as though it did.
@@ -1222,6 +1235,86 @@ impl AdjustPass {
return Err(e); return Err(e);
} }
}; };
// TRACES: FR-DEV-3j
// The view pass: the view transform and the output transform, after
// every kernel (D19). Its own uniform block, filled from the source
// like the fused pass's — it reads the non-linear flag and the film
// settings there — with the sample cache off, because the colour it
// reads is the detail stage's result, bound where the cache would be.
let view_uniforms = Self::fused_uniforms(source, view);
let view_params = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-view-params"),
contents: bytemuck::cast_slice(&view_uniforms),
usage: wgpu::BufferUsages::UNIFORM,
});
let (_, no_sample_out) = self.sample.views(SampleUse::Direct);
let target_view = self.targets[self.current]
.as_ref()
.expect("ensured above")
.view
.clone();
let view_bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("adjust-view-bg"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source.view()),
},
wgpu::BindGroupEntry {
binding: 1,
resource: view_params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&target_view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(
masks.map_or(&self.empty_masks, |m| m.view()),
),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::TextureView(&film_curves),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(&film_lut),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&result),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&no_sample_out),
},
],
});
{
let pipeline = self
.cache
.get(&view.structure_hash)
.expect("compiled above");
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("adjust-view-pass"),
timestamp_writes: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &view_bind_group, &[]);
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
}
self.view_dispatches += 1;
self.ctx.queue.submit(Some(enc.finish())); self.ctx.queue.submit(Some(enc.finish()));
self.detail_dispatches += ran; self.detail_dispatches += ran;
self.colour_key = Some((key, width, height)); self.colour_key = Some((key, width, height));
@@ -1257,22 +1350,13 @@ impl AdjustPass {
// runs. See `DemosaicedImage::is_non_linear`. // runs. See `DemosaicedImage::is_non_linear`.
let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 }; let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 };
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]); uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]);
// TRACES: FR-DEV-3e // TRACES: FR-DSP-2 | NFR-RES-2
// The camera profile's base curve, packed the way the generated block // Which part of the photograph the texture holds. The whole of it for
// declares it: four x, four y, then the fifth point and the flag. The // every source that fits in one texture, which writes back exactly
// flag is what lets one compiled shader serve a profiled body and an // what the composer put there.
// unprofiled one, so the pipeline cache is not split in two by which let w = dr_pipeline::SOURCE_WINDOW_UNIFORM_OFFSET;
// camera took the frame. uniforms[w..w + dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS]
// .copy_from_slice(&source.window_uniforms());
// Written here rather than at the call site so that *both* callers —
// the plain render and the masked one — carry the profile. Filling it
// at one of them was how the two halves of this merge each had it.
let curve = source.base_curve();
let on = if curve.is_identity() { 0.0 } else { 1.0 };
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
uniforms[b..b + 4].copy_from_slice(&curve.xs[0..4]);
uniforms[b + 4..b + 8].copy_from_slice(&curve.ys[0..4]);
uniforms[b + 8..b + 12].copy_from_slice(&[curve.xs[4], curve.ys[4], on, 0.0]);
uniforms uniforms
} }
@@ -1386,6 +1470,13 @@ impl AdjustPass {
self.detail_dispatches self.detail_dispatches
} }
/// TRACES: FR-DEV-3j
/// View passes encoded since this pass was created: one for every render
/// that had a detail stage, since the view transform follows it.
pub fn view_dispatches(&self) -> usize {
self.view_dispatches
}
/// How many linear intermediates have been allocated. For tests: see /// How many linear intermediates have been allocated. For tests: see
/// [`crate::MaskPass::allocations`] for the regression this catches. /// [`crate::MaskPass::allocations`] for the regression this catches.
pub fn detail_allocations(&self) -> usize { pub fn detail_allocations(&self) -> usize {
@@ -1429,9 +1520,9 @@ impl AdjustPass {
/// one: the storage format is in the layout. The profile uniforms are /// one: the storage format is in the layout. The profile uniforms are
/// filled neutral here rather than from the source, which is the whole /// filled neutral here rather than from the source, which is the whole
/// point of the mode (`OutputMode::CameraLinear`): unit white balance, /// point of the mode (`OutputMode::CameraLinear`): unit white balance,
/// identity matrix, base curve off. The non-linear flag is kept, so a /// identity matrix, and no view transform composed. The non-linear flag
/// JPEG source is still linearised — camera space for a JPEG is the /// is kept, so a JPEG source is still linearised — camera space for a
/// decoded values made linear, which is the best that exists. /// JPEG is the decoded values made linear, which is the best that exists.
/// ///
/// The texture stays on the device for a merge's warp to sample; see /// The texture stays on the device for a merge's warp to sample; see
/// [`Self::camera_texture`] and [`Self::read_camera_linear`]. /// [`Self::camera_texture`] and [`Self::read_camera_linear`].
@@ -1460,8 +1551,6 @@ impl AdjustPass {
uniforms[4..8].copy_from_slice(&[0.0, 1.0, 0.0, 0.0]); uniforms[4..8].copy_from_slice(&[0.0, 1.0, 0.0, 0.0]);
uniforms[8..12].copy_from_slice(&[0.0, 0.0, 1.0, 0.0]); uniforms[8..12].copy_from_slice(&[0.0, 0.0, 1.0, 0.0]);
uniforms[12..16].copy_from_slice(&[1.0, 1.0, 1.0, non_linear]); uniforms[12..16].copy_from_slice(&[1.0, 1.0, 1.0, non_linear]);
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
uniforms[b + 10] = 0.0;
let params_buf = self let params_buf = self
.ctx .ctx
@@ -1689,7 +1778,7 @@ pub(crate) fn numbered(src: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage}; use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_pipeline::ops::{colour_mixer, exposure, saturation}; use dr_pipeline::ops::{colour_mixer, exposure, saturation};
use dr_pipeline::EditGraph; use dr_pipeline::EditGraph;
// For `Operation::detail`, which is how `the_whole_chain_at_once_compiles` // For `Operation::detail`, which is how `the_whole_chain_at_once_compiles`
@@ -1727,7 +1816,6 @@ mod tests {
// Identity, so the test reasons about the operations alone // Identity, so the test reasons about the operations alone
// rather than about a camera's colour response. // rather than about a camera's colour response.
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -1931,7 +2019,6 @@ mod tests {
white_level: 16383, white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0], wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -2303,7 +2390,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; N * N * N], lut: vec![[0.5, 0.5, 0.5]; N * N * N],
density_max: 3.0, density_max: 3.0,
lut_size: N, lut_size: N,
grain_particles: [0.0; 3], push_stations: vec![0.0],
paper: None,
grain_particles: [[0.0; 3]; dr_pipeline::ops::film_sim::FORMAT_COUNT],
grain_density_max: [3.0; 3], grain_density_max: [3.0; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
} }
@@ -2361,7 +2450,7 @@ mod tests {
// find those operations in neither stage and fail for a reason that is // find those operations in neither stage and fail for a reason that is
// not a defect. Shadows the smaller size deliberately. // not a defect. Shadows the smaller size deliberately.
let (w, h) = g.output_size(512, 512); let (w, h) = g.output_size(512, 512);
let detail = g.compose_detail_for((512, 512), (w, h), dr_types::ColourSpace::Srgb); let detail = g.compose_detail((512, 512), (w, h));
assert!( assert!(
!detail.is_empty(), !detail.is_empty(),
"the detail half composed nothing, so nothing of it was compiled" "the detail half composed nothing, so nothing of it was compiled"
@@ -2381,7 +2470,20 @@ mod tests {
let mut fused_blocks = 0; let mut fused_blocks = 0;
for desc in g.descriptors() { for desc in g.descriptors() {
let id = desc.id.0; let id = desc.id.0;
let point = shader.source.contains(&format!("---- {id} ----")); // A stock is loaded here, and a stock is a rendering: the view
// transform it replaces is correctly in neither half (FR-DEV-3j).
if id == dr_pipeline::ops::view_transform::ID.0 {
assert!(!shader.source.contains("---- view_transform ----"));
continue;
}
// A view operation is in the view pass when a detail stage
// follows, which it does here (D19).
let block = format!("---- {id} ----");
let point = shader.source.contains(&block)
|| shader
.view
.as_ref()
.is_some_and(|v| v.source.contains(&block));
let neighbourhood = detail let neighbourhood = detail
.passes .passes
.iter() .iter()
@@ -2415,8 +2517,15 @@ mod tests {
// because the two catch different faults: the XOR catches an operation // because the two catch different faults: the XOR catches an operation
// in the wrong stage, this catches a block in the shader that nothing // in the wrong stage, this catches a block in the shader that nothing
// in the chain asked for. // in the chain asked for.
//
// The view pass repeats the prologue — framing and the warps — for
// the positions it publishes, so only its operation blocks count.
let view_blocks = shader
.view
.as_ref()
.map_or(0, |v| v.source.matches("---- ").count() - (warp_blocks + 1));
assert_eq!( assert_eq!(
shader.source.matches("---- ").count(), shader.source.matches("---- ").count() + view_blocks,
fused_blocks + warp_blocks + 1, fused_blocks + warp_blocks + 1,
"the fused shader carries a block nothing in the chain asked for" "the fused shader carries a block nothing in the chain asked for"
); );
@@ -2518,7 +2627,6 @@ mod tests {
white_level: 16383, white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0], wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -2622,7 +2730,6 @@ mod tests {
white_level: 16383, white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0], wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -3207,11 +3314,16 @@ mod tests {
} }
#[test] #[test]
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() { fn a_jpeg_and_sensor_data_differ_by_exactly_the_view_transform() {
// The two producers must be interchangeable. A mid-grey that is // TRACES: FR-DEV-3j
// linearly 0.216 (sRGB 128) arriving as sensor data and as a JPEG // The two producers must be interchangeable up to the rendering. A
// must render the same, or an edit would mean different things // mid-grey that is linearly 0.216 (sRGB 128) arriving as sensor data
// depending on which decoder opened the file. // is scene-referred and goes through the view transform; arriving as
// a JPEG it is already a rendering and must come out as it went in.
// Before D19 the fixture's identity base curve made both unrendered
// and this asserted they matched; what it guards is unchanged — the
// linearisation of each agrees — but the rendering between them is
// now always there for sensor data.
let Some(ctx) = ctx() else { return }; let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx); let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose(); let shader = EditGraph::default_chain().compose();
@@ -3230,11 +3342,18 @@ mod tests {
read_centre(&ctx, t) read_centre(&ctx, t)
}; };
let delta = (i32::from(from_sensor[0]) - i32::from(from_jpeg[0])).abs(); let scene = 3537.0 / 16383.0;
let viewed = dr_pipeline::view::Sigmoid::default_curve().channel(scene);
let expected = (dr_types::Transfer::Srgb.encode(viewed) * 255.0).round() as i32;
let delta = (i32::from(from_sensor[0]) - expected).abs();
assert!( assert!(
delta <= 3, delta <= 3,
"the same scene value rendered {from_sensor:?} from sensor data \ "sensor data rendered {from_sensor:?}, expected about {expected}"
and {from_jpeg:?} from a JPEG" );
let delta = (i32::from(from_jpeg[0]) - 128).abs();
assert!(
delta <= 3,
"a JPEG was rendered again: {from_jpeg:?} from sRGB 128"
); );
} }
} }
+407 -44
View File
@@ -10,7 +10,7 @@
//! pass over this texture; it does not re-demosaic, which is what keeps the //! pass over this texture; it does not re-demosaic, which is what keeps the
//! interaction budget (NFR-P9) reachable on a 24 MP file. //! interaction budget (NFR-P9) reachable on a 24 MP file.
use dr_decode::{BaseCurve, CfaPattern, RawImage}; use dr_decode::{CfaPattern, RawImage};
use wgpu::util::DeviceExt; use wgpu::util::DeviceExt;
use crate::{GpuContext, GpuError}; use crate::{GpuContext, GpuError};
@@ -57,6 +57,32 @@ struct XTransParams {
tile: [u32; 4], tile: [u32; 4],
} }
/// Uniform block for the hot-pixel repair. Layout must match
/// `hot_pixels.wgsl`.
///
/// One block for both colour filter arrays: the repair asks only "which
/// photosites share this one's colour", and a 6×6 tile answers that for a
/// Bayer cell as well as for X-Trans.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct HotPixelParams {
crop_x: u32,
crop_y: u32,
width: u32,
height: u32,
stride: u32,
words: u32,
row_invocations: u32,
samples: u32,
black: [f32; 4],
inv_range: [f32; 4],
tile: [u32; 4],
}
/// The repair's workgroup width. Must match `@workgroup_size` in
/// `hot_pixels.wgsl`.
const HOT_PIXEL_GROUP: u32 = 64;
/// A demosaiced image living on the GPU. /// A demosaiced image living on the GPU.
/// ///
/// RGBA16Float, scene-referred, camera colour space. This is the input every /// RGBA16Float, scene-referred, camera colour space. This is the input every
@@ -83,23 +109,25 @@ pub struct DemosaicedImage {
color_matrix: [f32; 9], color_matrix: [f32; 9],
/// As-shot white balance, the neutral starting point for the WB control. /// As-shot white balance, the neutral starting point for the WB control.
as_shot_wb: [f32; 3], as_shot_wb: [f32; 3],
/// TRACES: FR-DEV-3e
/// The camera profile's rendering curve, carried through for the adjust
/// pass exactly as `color_matrix` is.
///
/// It rides on the image rather than on the edit graph because it is not
/// an edit: it belongs to the body that took the frame, the way the
/// masked-photosite crop and the EXIF orientation do, and a sidecar shared
/// between two bodies must never carry one body's rendering onto the
/// other's file (FR-NC-9).
base_curve: BaseCurve,
/// Whether the texture holds gamma-encoded rather than linear values. /// Whether the texture holds gamma-encoded rather than linear values.
non_linear: bool, non_linear: bool,
/// Which upload this is, unique for the life of the process. See /// Which upload this is, unique for the life of the process. See
/// [`Self::id`]. /// [`Self::id`].
id: u64, id: u64,
/// TRACES: FR-DSP-2 | NFR-RES-2
/// The whole frame's size in pixels — what [`Self::size`] reports.
/// The texture's own size when it holds the whole frame at full
/// resolution, which is every photograph that fits in one.
frame: (u32, u32),
/// Which part of the frame the texture holds, as origin and extent in
/// normalised frame coordinates. `[0, 0, 1, 1]` for the whole frame,
/// reduced or not. See [`Self::window_uniforms`].
window: [f32; 4],
} }
/// The window of a texture that holds the whole frame.
const WHOLE_FRAME: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
/// The next [`DemosaicedImage::id`]. /// The next [`DemosaicedImage::id`].
fn next_image_id() -> u64 { fn next_image_id() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -117,10 +145,50 @@ impl DemosaicedImage {
&self.view &self.view
} }
/// The size of the photograph this stands for, in its own pixels.
///
/// **Not necessarily the texture's.** For a photograph larger than one
/// texture this is a reduced copy of it or a window cut from it, and
/// everything that sizes a render, a crop or a kernel has to go on
/// measuring the photograph. What indexes the texture's texels asks
/// [`Self::texture_size`] instead.
pub fn size(&self) -> (u32, u32) { pub fn size(&self) -> (u32, u32) {
self.frame
}
/// The texture's own size in texels.
pub fn texture_size(&self) -> (u32, u32) {
(self.width, self.height) (self.width, self.height)
} }
/// TRACES: FR-DSP-2 | NFR-RES-2
/// The source window uniforms the fused shader reads, in the order
/// `dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS` declares them.
///
/// The second `vec4` is zero for a texture that holds the whole frame at
/// full resolution, so the shader measures the texture itself exactly as
/// it did before windows existed.
pub fn window_uniforms(&self) -> [f32; dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS] {
let [x, y, w, h] = self.window;
let (fw, fh) = if self.is_whole() {
(0.0, 0.0)
} else {
(self.frame.0 as f32, self.frame.1 as f32)
};
[x, y, w, h, fw, fh, 0.0, 0.0]
}
/// Whether the texture is the whole frame at full resolution.
pub fn is_whole(&self) -> bool {
self.window == WHOLE_FRAME && self.frame == (self.width, self.height)
}
/// The window this texture holds, as origin and extent in normalised
/// frame coordinates.
pub fn window(&self) -> [f32; 4] {
self.window
}
/// Which texture this is, as a number that is never reused. /// Which texture this is, as a number that is never reused.
/// ///
/// For a cache that has to know it is still looking at the same pixels /// For a cache that has to know it is still looking at the same pixels
@@ -139,15 +207,6 @@ impl DemosaicedImage {
self.color_matrix self.color_matrix
} }
/// TRACES: FR-DEV-3e
/// The camera profile's base curve, as five `(x, y)` points.
///
/// [`BaseCurve::IDENTITY`] where the body is unprofiled or the source was
/// never raw, in which case the adjust pass skips the stage entirely.
pub fn base_curve(&self) -> BaseCurve {
self.base_curve
}
/// As-shot white balance multipliers, green-normalised. /// As-shot white balance multipliers, green-normalised.
/// ///
/// The white balance control is expressed *relative* to these, so its /// The white balance control is expressed *relative* to these, so its
@@ -261,13 +320,15 @@ impl DemosaicedImage {
color_matrix: IDENTITY_3X3, color_matrix: IDENTITY_3X3,
as_shot_wb: [1.0, 1.0, 1.0], as_shot_wb: [1.0, 1.0, 1.0],
// **The identity, and this is the whole reason the field is here // **The identity, and this is the whole reason the field is here
// rather than resolved further down.** A JPEG has already had its // rather than resolved further down.** A JPEG has already been
// camera's base curve baked in by the camera; applying one again // rendered by the camera; the view transform skips a source
// would render the rendering, crushing the shadows and flattening // flagged non-linear, since rendering the rendering would crush
// the highlights of an image that was already finished. // the shadows and flatten the highlights of an image that was
base_curve: BaseCurve::IDENTITY, // already finished.
non_linear: true, non_linear: true,
id: next_image_id(), id: next_image_id(),
frame: (width, height),
window: WHOLE_FRAME,
}) })
} }
} }
@@ -278,11 +339,46 @@ impl DemosaicedImage {
/// what a merge writes. No demosaic; the samples are normalised by the /// what a merge writes. No demosaic; the samples are normalised by the
/// file's black and white levels exactly as the demosaic kernel would /// file's black and white levels exactly as the demosaic kernel would
/// normalise a photosite, and everything else — the matrix, the /// normalise a photosite, and everything else — the matrix, the
/// balance, the body's base curve — is carried through as for a CFA /// balance, the view transform — is carried through as for a CFA
/// file, because the composite is developed as one photograph from the /// file, because the composite is developed as one photograph from the
/// body that took its sources. /// body that took its sources.
pub fn from_linear_rgb16(ctx: &GpuContext, raw: &RawImage) -> Result<Self, GpuError> { pub fn from_linear_rgb16(ctx: &GpuContext, raw: &RawImage) -> Result<Self, GpuError> {
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1)); let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
Self::linear_rgb16_window(ctx, raw, [0, 0, width, height], 1)
}
/// TRACES: FR-DSP-2 | NFR-RES-2
/// Part of a linear DNG, or a reduced copy of it, for a photograph too
/// large to hold in one texture.
///
/// `region` is `[x, y, width, height]` in pixels of the frame (the
/// file's crop), clamped to it. `reduce` averages `reduce × reduce`
/// blocks into one texel — a box filter, which is what a reduced copy
/// that is only ever displayed smaller than itself needs, and which keeps
/// the samples in scene-linear light where an average means something.
///
/// The texture then knows where it sits ([`Self::window`]) and how large
/// the photograph is ([`Self::size`]), and the fused shader maps each
/// output pixel's position in the *photograph* into it. So a crop, a
/// rotation or a mask drawn on the reduced copy lands on the same pixels
/// of a full-resolution window, and an export in tiles is the same
/// picture as one that fitted.
///
/// Refused only if the result itself does not fit the device.
pub fn linear_rgb16_window(
ctx: &GpuContext,
raw: &RawImage,
region: [u32; 4],
reduce: u32,
) -> Result<Self, GpuError> {
let frame = (raw.crop.width.max(1), raw.crop.height.max(1));
let k = reduce.max(1);
let x0 = region[0].min(frame.0 - 1);
let y0 = region[1].min(frame.1 - 1);
let rw = region[2].clamp(1, frame.0 - x0);
let rh = region[3].clamp(1, frame.1 - y0);
let (width, height) = (rw.div_ceil(k), rh.div_ceil(k));
let limits = ctx.device.limits(); let limits = ctx.device.limits();
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d { if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
return Err(GpuError::TooLarge(format!( return Err(GpuError::TooLarge(format!(
@@ -302,19 +398,49 @@ impl DemosaicedImage {
} }
let black = black_per_cell(raw); let black = black_per_cell(raw);
let inv = inv_range_per_cell(raw); let inv = inv_range_per_cell(raw);
// Per channel rather than per CFA cell: R, G, B are the first three.
let mut half: Vec<u16> = Vec::with_capacity((width * height * 4) as usize); // One output row per task: a 200-megapixel reduction is a second of
for y in 0..height as usize { // one core, and the rows are independent.
let row = (raw.crop.y as usize + y) * stride + raw.crop.x as usize * 3; let row_texels = width as usize * 4;
for x in 0..width as usize { let mut half = vec![0u16; row_texels * height as usize];
let p = &raw.data[row + x * 3..row + x * 3 + 3]; let fill_row = |ty: usize, out: &mut [u16]| {
for c in 0..3 { let sy0 = y0 as usize + ty * k as usize;
let v = (f32::from(p[c]) - black[c]) * inv[c]; let sy1 = (sy0 + k as usize).min((y0 + rh) as usize);
half.push(f32_to_f16_bits_unclamped(v)); for tx in 0..width as usize {
let sx0 = x0 as usize + tx * k as usize;
let sx1 = (sx0 + k as usize).min((x0 + rw) as usize);
let mut acc = [0f32; 3];
for sy in sy0..sy1 {
let row = (raw.crop.y as usize + sy) * stride + raw.crop.x as usize * 3;
for sx in sx0..sx1 {
let p = &raw.data[row + sx * 3..row + sx * 3 + 3];
for c in 0..3 {
acc[c] += f32::from(p[c]);
}
}
} }
half.push(f32_to_f16_bits(1.0)); let n = ((sy1 - sy0) * (sx1 - sx0)).max(1) as f32;
let texel = &mut out[tx * 4..tx * 4 + 4];
for c in 0..3 {
let v = (acc[c] / n - black[c]) * inv[c];
texel[c] = f32_to_f16_bits_unclamped(v);
}
texel[3] = f32_to_f16_bits(1.0);
} }
} };
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
let rows_per = (height as usize).div_ceil(threads).max(1);
std::thread::scope(|scope| {
for (chunk, rows) in half.chunks_mut(rows_per * row_texels).enumerate() {
let fill_row = &fill_row;
scope.spawn(move || {
for (i, out) in rows.chunks_mut(row_texels).enumerate() {
fill_row(chunk * rows_per + i, out);
}
});
}
});
let texture = ctx.device.create_texture_with_data( let texture = ctx.device.create_texture_with_data(
&ctx.queue, &ctx.queue,
&wgpu::TextureDescriptor { &wgpu::TextureDescriptor {
@@ -335,6 +461,17 @@ impl DemosaicedImage {
bytemuck::cast_slice(&half), bytemuck::cast_slice(&half),
); );
let view = texture.create_view(&Default::default()); let view = texture.create_view(&Default::default());
// The extent is the texels' own, `width × k`, not the region's: the
// last block of a reduction may run past the frame's edge, and
// stretching it to fit would put every texel slightly off the
// pixels it averaged. The shader's bounds test is on the frame, so
// nothing past the edge is ever read.
let window = [
x0 as f32 / frame.0 as f32,
y0 as f32 / frame.1 as f32,
(width * k) as f32 / frame.0 as f32,
(height * k) as f32 / frame.1 as f32,
];
Ok(Self { Ok(Self {
texture, texture,
view, view,
@@ -342,9 +479,10 @@ impl DemosaicedImage {
height, height,
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3), color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]], as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
base_curve: raw.base_curve,
non_linear: false, non_linear: false,
id: next_image_id(), id: next_image_id(),
frame,
window,
}) })
} }
} }
@@ -437,6 +575,8 @@ pub struct Demosaicer {
pipeline: wgpu::ComputePipeline, pipeline: wgpu::ComputePipeline,
xtrans_pipeline: wgpu::ComputePipeline, xtrans_pipeline: wgpu::ComputePipeline,
bind_group_layout: wgpu::BindGroupLayout, bind_group_layout: wgpu::BindGroupLayout,
hot_pixel_pipeline: wgpu::ComputePipeline,
hot_pixel_layout: wgpu::BindGroupLayout,
} }
impl Demosaicer { impl Demosaicer {
@@ -527,11 +667,15 @@ impl Demosaicer {
cache: None, cache: None,
}); });
let (hot_pixel_pipeline, hot_pixel_layout) = hot_pixel_pipeline(ctx);
Ok(Self { Ok(Self {
ctx: ctx.clone(), ctx: ctx.clone(),
pipeline, pipeline,
xtrans_pipeline, xtrans_pipeline,
bind_group_layout, bind_group_layout,
hot_pixel_pipeline,
hot_pixel_layout,
}) })
} }
@@ -558,8 +702,12 @@ impl Demosaicer {
// the buffer outlive the `if` that chose them. // the buffer outlive the `if` that chose them.
let bayer_params; let bayer_params;
let xtrans_params; let xtrans_params;
// Kept for the hot-pixel repair: finding the X-Trans phase reads the
// whole frame on the CPU, and once per photograph is enough.
let mut xtrans_tile = None;
let (pipeline, params_bytes) = if raw.cfa_pattern.is_xtrans() { let (pipeline, params_bytes) = if raw.cfa_pattern.is_xtrans() {
xtrans_params = xtrans_params_for(raw, width, height); xtrans_params = xtrans_params_for(raw, width, height);
xtrans_tile = Some(xtrans_params.tile);
(&self.xtrans_pipeline, bytemuck::bytes_of(&xtrans_params)) (&self.xtrans_pipeline, bytemuck::bytes_of(&xtrans_params))
} else { } else {
let pattern = match raw.cfa_pattern { let pattern = match raw.cfa_pattern {
@@ -598,6 +746,64 @@ impl Demosaicer {
usage: wgpu::BufferUsages::STORAGE, usage: wgpu::BufferUsages::STORAGE,
}); });
// TRACES: FR-RAW-3
// The mosaic the demosaic actually reads: the readout with its hot and
// dead photosites repaired. A second buffer rather than in place,
// because every photosite's verdict reads its neighbours' originals.
let repaired = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("raw-repaired"),
size: raw_buf.size(),
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
});
let words = packed.len() as u32;
let groups = words.div_ceil(HOT_PIXEL_GROUP).max(1);
// A 24 MP readout is 190,000 workgroups, past the 65,535 one
// dispatch dimension may hold, so the grid folds into rows.
let groups_x = groups.min(
self.ctx
.device
.limits()
.max_compute_workgroups_per_dimension,
);
let groups_y = groups.div_ceil(groups_x);
let hot_params = hot_pixel_params(
raw,
(width, height),
words,
groups_x * HOT_PIXEL_GROUP,
xtrans_tile,
);
let hot_params_buf =
self.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hot-pixel-params"),
contents: bytemuck::bytes_of(&hot_params),
usage: wgpu::BufferUsages::UNIFORM,
});
let hot_bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("hot-pixel-bg"),
layout: &self.hot_pixel_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: raw_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: hot_params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: repaired.as_entire_binding(),
},
],
});
let params_buf = self let params_buf = self
.ctx .ctx
.device .device
@@ -636,7 +842,7 @@ impl Demosaicer {
entries: &[ entries: &[
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 0, binding: 0,
resource: raw_buf.as_entire_binding(), resource: repaired.as_entire_binding(),
}, },
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 1, binding: 1,
@@ -655,6 +861,18 @@ impl Demosaicer {
.create_command_encoder(&wgpu::CommandEncoderDescriptor { .create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("demosaic-encoder"), label: Some("demosaic-encoder"),
}); });
// Two passes in one submission. wgpu orders a storage write in one
// pass before a read of the same buffer in the next, so the demosaic
// sees every repair.
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("hot-pixel-pass"),
timestamp_writes: None,
});
pass.set_pipeline(&self.hot_pixel_pipeline);
pass.set_bind_group(0, &hot_bind_group, &[]);
pass.dispatch_workgroups(groups_x, groups_y, 1);
}
{ {
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("demosaic-pass"), label: Some("demosaic-pass"),
@@ -678,12 +896,13 @@ impl Demosaicer {
// Whatever the profile database had for this body (FR-DEV-3e), // Whatever the profile database had for this body (FR-DEV-3e),
// resolved at decode because that is the only place the make and // resolved at decode because that is the only place the make and
// model are known. // model are known.
base_curve: raw.base_curve,
// Sensor data is linear by construction — the demosaic shader // Sensor data is linear by construction — the demosaic shader
// normalises against black and white levels and applies no // normalises against black and white levels and applies no
// transfer function. // transfer function.
non_linear: false, non_linear: false,
id: next_image_id(), id: next_image_id(),
frame: (width, height),
window: WHOLE_FRAME,
}) })
} }
} }
@@ -960,6 +1179,136 @@ fn detect_xtrans_phase(raw: &RawImage) -> (u32, u32) {
/// TRACES: FR-RAW-5 /// TRACES: FR-RAW-5
/// Everything the X-Trans shader needs about one image. /// Everything the X-Trans shader needs about one image.
/// TRACES: FR-RAW-3
/// The hot-pixel repair's pipeline and its three bindings: the readout, the
/// uniform block, and the repaired copy it writes.
fn hot_pixel_pipeline(ctx: &GpuContext) -> (wgpu::ComputePipeline, wgpu::BindGroupLayout) {
let shader = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("hot-pixels"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/hot_pixels.wgsl").into()),
});
let storage = |binding, read_only| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let layout = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("hot-pixel-bgl"),
entries: &[
storage(0, true),
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
storage(2, false),
],
});
let pipeline_layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("hot-pixel-layout"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let pipeline = ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("hot-pixel-pipeline"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
(pipeline, layout)
}
/// The colour of each position of a Bayer cell, row-major, for the pattern
/// the decoder reported: 0=R, 1=G, 2=B. `None` for anything that is not a
/// 2×2 pattern.
fn bayer_cell(pattern: CfaPattern) -> Option<[u32; 4]> {
match pattern {
CfaPattern::Rggb => Some([0, 1, 1, 2]),
CfaPattern::Bggr => Some([2, 1, 1, 0]),
CfaPattern::Grbg => Some([1, 0, 2, 1]),
CfaPattern::Gbrg => Some([1, 2, 0, 1]),
_ => None,
}
}
/// A Bayer cell as the 6×6 sensor-anchored tile the repair indexes.
///
/// The decoder's pattern is phased for the *crop* origin, and the tile is
/// indexed by sensor coordinate, so each position is shifted by the crop.
/// Six is even, so a column's parity modulo 6 is its parity outright and the
/// cell repeats cleanly.
fn pack_bayer_tile(cell: [u32; 4], crop_x: u32, crop_y: u32) -> [u32; 4] {
let mut out = [0u32; 4];
for row in 0..6u32 {
for col in 0..6u32 {
let i = (((row + crop_y) & 1) * 2 + ((col + crop_x) & 1)) as usize;
out[(row >> 1) as usize] |= cell[i] << ((row & 1) * 12 + col * 2);
}
}
out
}
/// The repair's uniforms for one readout.
///
/// `xtrans_tile` is the tile the X-Trans demosaic was given, when it was one;
/// anything else must be a Bayer pattern, which `run` has already checked.
fn hot_pixel_params(
raw: &RawImage,
(width, height): (u32, u32),
words: u32,
row_invocations: u32,
xtrans_tile: Option<[u32; 4]>,
) -> HotPixelParams {
let (black, inv_range, tile) = match (xtrans_tile, bayer_cell(raw.cfa_pattern)) {
(Some(tile), _) => {
let (black, inv_range) = xtrans_levels(raw);
([black; 4], [inv_range; 4], tile)
}
(None, Some(cell)) => (
black_per_cell(raw),
inv_range_per_cell(raw),
pack_bayer_tile(cell, raw.crop.x, raw.crop.y),
),
// Not reached from `run`, which refuses any other pattern before
// this. A zero tile judges every photosite against all of its
// neighbours, which is right for a sensor with no colour filter.
(None, None) => (black_per_cell(raw), inv_range_per_cell(raw), [0; 4]),
};
HotPixelParams {
crop_x: raw.crop.x,
crop_y: raw.crop.y,
width,
height,
stride: raw.width,
words,
row_invocations,
samples: raw.data.len() as u32,
black,
inv_range,
tile,
}
}
fn xtrans_params_for(raw: &RawImage, width: u32, height: u32) -> XTransParams { fn xtrans_params_for(raw: &RawImage, width: u32, height: u32) -> XTransParams {
let (black, inv_range) = xtrans_levels(raw); let (black, inv_range) = xtrans_levels(raw);
let wb = wb_gains(raw); let wb = wb_gains(raw);
@@ -994,6 +1343,24 @@ mod tests {
} }
} }
/// The repair's tile is indexed by sensor coordinate, the decoder's
/// pattern by crop coordinate. A crop at an odd origin must shift one
/// into the other, or the repair compares red with green.
#[test]
fn the_bayer_tile_is_anchored_to_the_sensor_not_the_crop() {
let cell = bayer_cell(CfaPattern::Rggb).unwrap();
let colour = |tile: [u32; 4], x: u32, y: u32| {
(tile[((y % 6) >> 1) as usize] >> (((y % 6) & 1) * 12 + (x % 6) * 2)) & 3
};
for (cx, cy) in [(0, 0), (1, 0), (0, 1), (1, 1), (7, 4)] {
let tile = pack_bayer_tile(cell, cx, cy);
// Red is the crop's first photosite, wherever the crop starts.
assert_eq!(colour(tile, cx, cy), 0, "crop at ({cx}, {cy})");
assert_eq!(colour(tile, cx + 1, cy + 1), 2, "crop at ({cx}, {cy})");
assert_eq!(colour(tile, cx + 1, cy), 1, "crop at ({cx}, {cy})");
}
}
#[test] #[test]
fn unclamped_half_keeps_shadows_signs_and_highlights() { fn unclamped_half_keeps_shadows_signs_and_highlights() {
// A 14-bit LSB, normalised: subnormal in f16, and must not be zero. // A 14-bit LSB, normalised: subnormal in f16, and must not be zero.
@@ -1030,7 +1397,6 @@ mod tests {
white_level: white, white_level: white,
wb_coeffs: [1.0, 1.0, 1.0, 1.0], wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None, color_matrix: None,
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -1146,7 +1512,6 @@ mod tests {
white_level: white, white_level: white,
wb_coeffs: [1.0, 1.0, 1.0, 1.0], wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None, color_matrix: None,
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -1443,7 +1808,6 @@ mod tests {
white_level: 16383, white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0], wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None, color_matrix: None,
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -1528,7 +1892,6 @@ mod tests {
1.0, 1.0,
], ],
color_matrix: None, color_matrix: None,
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
+32 -46
View File
@@ -43,12 +43,12 @@
//! dispatch is skipped, and dragging a sharpening slider costs the detail //! dispatch is skipped, and dragging a sharpening slider costs the detail
//! passes alone (FR-DEV-3d). //! passes alone (FR-DEV-3d).
//! //!
//! The remaining passes alternate between slots 1 and 2, and the last one //! The passes alternate between slots 1 and 2, the last one included: since
//! writes the display texture directly rather than an intermediate — so a //! D19 it hands its result to the adjust pass's **view pass**, which performs
//! chain of *N* passes costs *N* dispatches and not *N* + 1, and there is no //! the view transform and the output transform after every kernel, so no
//! resolve pass to pay for. That leaves the allocation at `1 + min(N-1, 2)` //! detail pass writes the display texture. A chain of *N* passes costs *N*
//! textures: one for a single-pass operation, two for a separable blur, three //! dispatches plus that one, and the allocation is `1 + min(N, 2)` textures.
//! however long the chain gets after that. //! An empty chain costs the view pass alone, reading slot 0.
//! //!
//! # The reduced chain, and why a second one was needed //! # The reduced chain, and why a second one was needed
//! //!
@@ -186,10 +186,9 @@ impl Intermediates {
/// intermediate against a fresh colour result and never be told. /// intermediate against a fresh colour result and never be told.
pub(crate) struct DetailRunner { pub(crate) struct DetailRunner {
ctx: GpuContext, ctx: GpuContext,
/// Layout for a pass writing another linear intermediate. /// Layout for every pass: each writes a linear intermediate, the last
/// one included, and the adjust pass's view pass reads the last (D19).
to_linear: Layout, to_linear: Layout,
/// Layout for the last pass, which writes the display texture.
to_output: Layout,
/// Compiled pipelines by pass structure hash. /// Compiled pipelines by pass structure hash.
cache: HashMap<u64, wgpu::ComputePipeline>, cache: HashMap<u64, wgpu::ComputePipeline>,
pool: Intermediates, pool: Intermediates,
@@ -255,7 +254,6 @@ impl DetailRunner {
Self { Self {
ctx: ctx.clone(), ctx: ctx.clone(),
to_linear: Layout::new(ctx, INTERMEDIATE_FORMAT, "detail-linear"), to_linear: Layout::new(ctx, INTERMEDIATE_FORMAT, "detail-linear"),
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
cache: HashMap::new(), cache: HashMap::new(),
pool: Intermediates::new(), pool: Intermediates::new(),
reduced: Intermediates::new(), reduced: Intermediates::new(),
@@ -274,27 +272,30 @@ impl DetailRunner {
width: u32, width: u32,
height: u32, height: u32,
) -> &wgpu::TextureView { ) -> &wgpu::TextureView {
// One for the colour pass's result, then one per hand-off between // One for the colour pass's result, then one per pass, capped at two
// detail passes, capped at two because a ping-pong needs no more: the // because a ping-pong needs no more. The last pass writes an
// last pass writes the display texture rather than an intermediate. // intermediate like the others since D19 — the view pass reads it —
let needed = 1 + passes.saturating_sub(1).min(2); // so a one-pass chain needs two slots where it used to need one.
let needed = 1 + passes.min(2);
self.pool.ensure(&self.ctx, needed, width, height); self.pool.ensure(&self.ctx, needed, width, height);
&self.pool.slots[0].view &self.pool.slots[0].view
} }
/// Encode every pass of `chain`, the last one writing `output`. /// Encode every pass of `chain`, and return how many ran and the view
/// the last one wrote — slot 0, the colour pass's own result, for an
/// empty chain.
/// ///
/// The caller must already have run the fused colour pass into /// The caller must already have run the fused colour pass into
/// [`Self::colour_target`] — or established that a previous frame's is /// [`Self::colour_target`] — or established that a previous frame's is
/// still valid, which is the whole point of keeping slot 0. /// still valid, which is the whole point of keeping slot 0 — and reads the
/// returned view in the view pass that finishes the render (D19).
pub(crate) fn encode( pub(crate) fn encode(
&mut self, &mut self,
encoder: &mut wgpu::CommandEncoder, encoder: &mut wgpu::CommandEncoder,
chain: &ComposedDetail, chain: &ComposedDetail,
output: &wgpu::TextureView,
width: u32, width: u32,
height: u32, height: u32,
) -> Result<usize, GpuError> { ) -> Result<(usize, wgpu::TextureView), GpuError> {
for pass in &chain.passes { for pass in &chain.passes {
self.compile(pass)?; self.compile(pass)?;
} }
@@ -340,17 +341,15 @@ impl DetailRunner {
for pass in chain.passes.iter() { for pass in chain.passes.iter() {
let scaled = pass.output_scale > 1; let scaled = pass.output_scale > 1;
// The last pass carries the output transform into the display // The last pass hands the view pass its input, which is read at
// texture, which is the render size by definition. A scaled pass // the render size by definition. A scaled pass there would leave
// there would bind a shader dispatching over a quarter-size grid // the result in the reduced chain and the view pass would read the
// to a full-size target and write a quarter of the picture — a // full-size slot before it — a wrong image rather than a
// wrong image rather than a validation failure, so it is caught // validation failure, so it is caught here and named.
// here and named. if scaled && std::ptr::eq(pass, chain.passes.last().expect("iterating")) {
if scaled && pass.writes_output {
return Err(GpuError::ShaderCompilation(format!( return Err(GpuError::ShaderCompilation(format!(
"detail pass {} declares output_scale {} and is last in \ "detail pass {} declares output_scale {} and is last in \
the chain; the output transform is written at the render \ the chain; the view pass reads the render size",
size",
pass.label, pass.output_scale pass.label, pass.output_scale
))); )));
} }
@@ -365,17 +364,14 @@ impl DetailRunner {
}; };
// Read what the previous pass in *this pass's own chain* wrote; // Read what the previous pass in *this pass's own chain* wrote;
// write the next slot of it, or the display texture if this is the // write the next slot of it. Alternating slots is what stops a pass reading the
// last pass. Alternating slots is what stops a pass reading the
// texture it is writing — on a compute pass that is not an error // texture it is writing — on a compute pass that is not an error
// the driver reports, merely a picture that depends on scheduling. // the driver reports, merely a picture that depends on scheduling.
let source = match (scaled, carried) { let source = match (scaled, carried) {
(true, Some(slot)) => &self.reduced.slots[slot].view, (true, Some(slot)) => &self.reduced.slots[slot].view,
_ => &self.pool.slots[full].view, _ => &self.pool.slots[full].view,
}; };
let destination = if pass.writes_output { let destination = if scaled {
output
} else if scaled {
&self.reduced.slots[reduced_writes % 2].view &self.reduced.slots[reduced_writes % 2].view
} else { } else {
&self.pool.slots[1 + (full_writes % 2)].view &self.pool.slots[1 + (full_writes % 2)].view
@@ -387,11 +383,7 @@ impl DetailRunner {
Some(slot) => &self.reduced.slots[slot].view, Some(slot) => &self.reduced.slots[slot].view,
None => &self.no_reduced, None => &self.no_reduced,
}; };
let layout = if pass.writes_output { let layout = &self.to_linear;
&self.to_output
} else {
&self.to_linear
};
let params = self let params = self
.ctx .ctx
@@ -464,9 +456,7 @@ impl DetailRunner {
compute.dispatch_workgroups(dispatch_w.div_ceil(8), dispatch_h.div_ceil(8), 1); compute.dispatch_workgroups(dispatch_w.div_ceil(8), dispatch_h.div_ceil(8), 1);
drop(compute); drop(compute);
if pass.writes_output { if scaled {
// Nothing downstream to hand anything to.
} else if scaled {
carried = Some(reduced_writes % 2); carried = Some(reduced_writes % 2);
reduced_writes += 1; reduced_writes += 1;
} else { } else {
@@ -480,7 +470,7 @@ impl DetailRunner {
} }
} }
Ok(chain.passes.len()) Ok((chain.passes.len(), self.pool.slots[full].view.clone()))
} }
/// Compile one pass, or leave the cached pipeline in place. /// Compile one pass, or leave the cached pipeline in place.
@@ -508,11 +498,7 @@ impl DetailRunner {
source: wgpu::ShaderSource::Wgsl(pass.source.as_str().into()), source: wgpu::ShaderSource::Wgsl(pass.source.as_str().into()),
}); });
let layout = if pass.writes_output { let layout = &self.to_linear;
&self.to_output
} else {
&self.to_linear
};
let pipeline = self let pipeline = self
.ctx .ctx
+1 -1
View File
@@ -928,7 +928,7 @@ impl MaskPass {
// the only readers and they are skipped in that case. // the only readers and they are skipped in that case.
let source_step = match source { let source_step = match source {
Some(image) => { Some(image) => {
let (sw, sh) = image.size(); let (sw, sh) = image.texture_size();
[ [
sw as f32 / width.max(1) as f32, sw as f32 / width.max(1) as f32,
sh as f32 / height.max(1) as f32, sh as f32 / height.max(1) as f32,
+13 -2
View File
@@ -73,6 +73,10 @@ pub struct MergeOutput {
pub chunk: (u32, u32), pub chunk: (u32, u32),
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale. /// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
pub sample_scale: f32, pub sample_scale: f32,
/// The white balance the composite will be developed with — the
/// inverse of its `AsShotNeutral` — so that a blown sample can be
/// written as the camera value that balance calls grey.
pub balance: [f32; 3],
} }
impl MergeOutput { impl MergeOutput {
@@ -109,7 +113,8 @@ struct WarpParams {
tile_origin: [f32; 2], tile_origin: [f32; 2],
tile_size: [u32; 2], tile_size: [u32; 2],
feather: f32, feather: f32,
_pad: f32, clip_onset: f32,
balance: [f32; 4],
} }
#[repr(C)] #[repr(C)]
@@ -334,7 +339,13 @@ impl MergePass {
tile_origin: [rect.0 as f32, rect.1 as f32], tile_origin: [rect.0 as f32, rect.1 as f32],
tile_size: [rect.2, rect.3], tile_size: [rect.2, rect.3],
feather: output.feather, feather: output.feather,
_pad: 0.0, clip_onset: dr_pipeline::CLIP_ONSET,
balance: [
output.balance[0].max(1e-3),
output.balance[1].max(1e-3),
output.balance[2].max(1e-3),
0.0,
],
}; };
self.accumulate(&params, tile); self.accumulate(&params, tile);
} }
+2 -2
View File
@@ -28,8 +28,8 @@
//! than leaving the specification and the code silently disagreeing. //! than leaving the specification and the code silently disagreeing.
//! //!
//! That texture is the right one on the merits. It is camera-native: no white //! That texture is the right one on the merits. It is camera-native: no white
//! balance has been applied, no camera matrix, no base curve, no tone curve, //! balance has been applied, no camera matrix, no tone curve, no view
//! no output transform. It is normalised by the sensor's own black and white //! transform, no output transform. It is normalised by the sensor's own black and white
//! levels, so 1.0 is saturation by construction and the distribution below it //! levels, so 1.0 is saturation by construction and the distribution below it
//! *is* the headroom question, with no calibration to carry and no origin to //! *is* the headroom question, with no calibration to carry and no origin to
//! choose. //! choose.
+1 -1
View File
@@ -208,7 +208,7 @@ impl SegmentPass {
source: &DemosaicedImage, source: &DemosaicedImage,
opts: SegmentOptions, opts: SegmentOptions,
) -> Result<Segmentation, GpuError> { ) -> Result<Segmentation, GpuError> {
let (src_w, src_h) = source.size(); let (src_w, src_h) = source.texture_size();
let (width, height) = proxy_size(src_w, src_h, opts.max_edge); let (width, height) = proxy_size(src_w, src_h, opts.max_edge);
let n = (width * height) as u64; let n = (width * height) as u64;
+185
View File
@@ -0,0 +1,185 @@
// Hot and dead photosite repair, on the raw mosaic, before demosaic.
//
// A hot photosite reads far above anything the light put there — a leaky
// well, lit by its own dark current on a long or high-ISO exposure. Left in,
// the demosaic spreads it into its neighbours' interpolated channels and it
// becomes a coloured cross, three pixels wide, that no later stage can take
// back out: by then it is five pixels of plausible colour rather than one
// photosite of nonsense. So it is repaired here, where it is still one value.
//
// **What counts as hot.** A photosite far above *every* photosite of its own
// colour in its 5x5 window, and also far above every one of its eight
// immediate neighbours whatever their colour. The second half is what keeps a
// star or a glint: real light arrives through a lens and an anti-aliasing
// filter, so even the sharpest point lands on a patch of photosites, and the
// ones beside it are lit too. A hot photosite's neighbours are as dark as the
// rest of the frame. Dead photosites are the mirror image and are handled the
// same way.
//
// **What it becomes.** The brightest (for a hot photosite) or darkest (for a
// dead one) same-colour neighbour — the value nearest to what it read that
// the neighbourhood can vouch for. An average would soften the one case this
// gets wrong, a real highlight that happened to pass both tests; a clamp to
// the neighbourhood's range cannot invent anything.
//
// Written for either colour filter array: the colour of a photosite comes from
// a 6x6 tile anchored to the sensor, which holds the X-Trans pattern as it is
// and a Bayer 2x2 cell repeated nine times.
struct HotPixelParams {
// The cropped area, in sensor photosites. Only photosites inside it are
// judged, and only photosites inside it are asked as neighbours — the
// masked border sits at black and would make everything look hot.
crop_x: u32,
crop_y: u32,
width: u32,
height: u32,
// Row stride of the readout, in samples, and the number of u32 words.
stride: u32,
words: u32,
// How many invocations one row of the dispatch grid holds, so a frame
// wider than a dispatch dimension can be addressed as two.
row_invocations: u32,
// Samples in the readout. One less than twice `words` when the count is
// odd, and the padding half of the last word is never judged.
samples: u32,
// Per-position black levels and reciprocal ranges, indexed by the
// photosite's parity within the *crop*: (y&1)*2 + (x&1) counted from its
// origin, as the demosaic counts them.
black: vec4<f32>,
inv_range: vec4<f32>,
// The 6x6 colour tile, two bits per photosite, indexed by sensor
// coordinate modulo 6: word k holds row 2k in its low 12 bits and row
// 2k+1 in the next 12. The fourth word is padding.
tile: vec4<u32>,
}
@group(0) @binding(0) var<storage, read> raw: array<u32>;
@group(0) @binding(1) var<uniform> params: HotPixelParams;
@group(0) @binding(2) var<storage, read_write> repaired: array<u32>;
// How far above its brightest neighbour a photosite must read to be hot, as a
// ratio and a margin in normalised units. Twice the neighbourhood and two
// percent of the range above it: far enough that shot noise in a lit area
// never qualifies, near enough that a hot photosite in a night sky — reading
// a third of the range over a sky at one percent — always does.
const HOT_RATIO: f32 = 2.0;
const HOT_MARGIN: f32 = 0.02;
// A dead photosite reads under half its darkest neighbour, and only counts
// where that neighbour is at least this bright: in the shadows, a photosite
// at zero is noise that clipped at the black point, not a defect.
const DEAD_RATIO: f32 = 0.5;
const DEAD_FLOOR: f32 = 0.05;
fn value_at(index: u32) -> u32 {
let word = raw[index >> 1u];
return select(word & 0xFFFFu, word >> 16u, (index & 1u) == 1u);
}
fn colour_at(sx: u32, sy: u32) -> u32 {
let row = sy % 6u;
let col = sx % 6u;
let word = params.tile[row >> 1u];
return (word >> ((row & 1u) * 12u + col * 2u)) & 3u;
}
// A raw value against its own black level and range. Compared rather than
// stored, so it is left unclamped at the top: a hot photosite above white is
// still more above white than its neighbours are.
fn level(sx: u32, sy: u32, v: u32) -> f32 {
let cell = ((sy - params.crop_y) & 1u) * 2u + ((sx - params.crop_x) & 1u);
return max(f32(v) - params.black[cell], 0.0) * params.inv_range[cell];
}
// The value to store for the photosite at `index`.
fn repair(index: u32) -> u32 {
let v = value_at(index);
let sx = index % params.stride;
let sy = index / params.stride;
if (sx < params.crop_x || sy < params.crop_y
|| sx >= params.crop_x + params.width || sy >= params.crop_y + params.height) {
return v;
}
let centre = level(sx, sy, v);
let colour = colour_at(sx, sy);
var same_hi = -1.0;
var same_lo = 1.0e9;
var same_hi_raw = v;
var same_lo_raw = v;
var same_count = 0u;
var adjacent_hi = 0.0;
var adjacent_lo = 1.0e9;
for (var dy = -2; dy <= 2; dy++) {
for (var dx = -2; dx <= 2; dx++) {
if (dx == 0 && dy == 0) {
continue;
}
let nx = i32(sx) + dx;
let ny = i32(sy) + dy;
if (nx < i32(params.crop_x) || ny < i32(params.crop_y)
|| nx >= i32(params.crop_x + params.width)
|| ny >= i32(params.crop_y + params.height)) {
continue;
}
let nsx = u32(nx);
let nsy = u32(ny);
let nv = value_at(nsy * params.stride + nsx);
let n = level(nsx, nsy, nv);
if (abs(dx) <= 1 && abs(dy) <= 1) {
adjacent_hi = max(adjacent_hi, n);
adjacent_lo = min(adjacent_lo, n);
}
if (colour_at(nsx, nsy) == colour) {
same_count += 1u;
if (n > same_hi) {
same_hi = n;
same_hi_raw = nv;
}
if (n < same_lo) {
same_lo = n;
same_lo_raw = nv;
}
}
}
}
// A corner of the crop can leave a photosite with a single same-colour
// neighbour, and one witness is not a neighbourhood.
if (same_count < 2u) {
return v;
}
let hot_line_same = same_hi * HOT_RATIO + HOT_MARGIN;
let hot_line_adjacent = adjacent_hi * HOT_RATIO + HOT_MARGIN;
if (centre > hot_line_same && centre > hot_line_adjacent) {
return same_hi_raw;
}
if (same_lo >= DEAD_FLOOR && centre < same_lo * DEAD_RATIO
&& centre < adjacent_lo * DEAD_RATIO) {
return same_lo_raw;
}
return v;
}
// One invocation per u32 word: two photosites, packed as the demosaic reads
// them. A word may straddle two rows when the stride is odd, which `repair`
// does not mind — it addresses by sample index.
@compute @workgroup_size(64, 1, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let word = gid.y * params.row_invocations + gid.x;
if (word >= params.words) {
return;
}
let lo = repair(word * 2u);
// The padding half of an odd-length readout is copied, not judged: it is
// not a photosite, and the demosaic never addresses it.
var hi = raw[word] >> 16u;
if (word * 2u + 1u < params.samples) {
hi = repair(word * 2u + 1u);
}
repaired[word] = (lo & 0xFFFFu) | (hi << 16u);
}
+17 -2
View File
@@ -46,7 +46,10 @@ struct Params {
tile_size: vec2<u32>, tile_size: vec2<u32>,
// Pixels over which the weight ramps from the edge to full. // Pixels over which the weight ramps from the edge to full.
feather: f32, feather: f32,
_pad: f32, // Where a sample starts to count as blown (`CLIP_ONSET`), and the
// white balance the composite will be developed with.
clip_onset: f32,
balance: vec4<f32>,
}; };
@group(0) @binding(0) var<uniform> p: Params; @group(0) @binding(0) var<uniform> p: Params;
@@ -125,7 +128,19 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
return; return;
} }
// Colour is the alpha-weighted mean of the texels that exist. // Colour is the alpha-weighted mean of the texels that exist.
let rgb = s.rgb / s.a * p.gain; let cam = s.rgb / s.a;
// **A blown sample is written as grey, before the gain.** A clipped
// photosite arrives as (1, 1, 1), which is not a colour: balanced, it
// is magenta, and the develop's highlight desaturation only rescues it
// while it is still at the white level. A gain below one moved it off
// that level, and a feather mixed it into a neighbour's real sky, so
// the composite's blown clouds came out pink. Written instead as the
// camera value the balance maps to grey — the develop pipeline's own
// neutral, the brightest balanced channel — it survives both.
let clipped = smoothstep(p.clip_onset, 1.0, max(cam.r, max(cam.g, cam.b)));
let balanced = cam * p.balance.rgb;
let grey = vec3<f32>(max(balanced.r, max(balanced.g, balanced.b))) / p.balance.rgb;
let rgb = mix(cam, grey, clipped) * p.gain;
let wa = w * s.a; let wa = w * s.a;
let i = gid.y * p.chunk_size.x + gid.x; let i = gid.y * p.chunk_size.x + gid.x;
acc[i] = acc[i] + vec4<f32>(rgb * wa, wa); acc[i] = acc[i] + vec4<f32>(rgb * wa, wa);
+1 -1
View File
@@ -3,7 +3,7 @@
// //
// The shader beside this one, `histogram.wgsl`, counts the frame the display // The shader beside this one, `histogram.wgsl`, counts the frame the display
// is about to show: an 8-bit code value, after white balance, the camera // is about to show: an 8-bit code value, after white balance, the camera
// matrix, the base curve, the tone curve and the output transform. This one // matrix, the tone curve, the view transform and the output transform. This one
// counts the texture the demosaic wrote, before any of that. The two differ in // counts the texture the demosaic wrote, before any of that. The two differ in
// exactly one place — the axis — and everything else here is deliberately the // exactly one place — the axis — and everything else here is deliberately the
// same construction, because the two reductions have the same shape and any // same construction, because the two reductions have the same shape and any
-181
View File
@@ -1,181 +0,0 @@
//! TRACES: FR-DEV-3e
//! The camera profile's base curve, end to end on a device.
//!
//! The unit tests either side of this one check halves. `dr-decode` asserts
//! that the shipped database parses and that every curve in it lifts its
//! midtones; `dr-pipeline` asserts that the generated WGSL evaluates a curve
//! in the right place. Neither would notice if the two agreed with each other
//! and both were wrong — a curve packed into the wrong uniform slots, or a
//! flag read from the wrong component, satisfies both and renders nothing.
//!
//! So this renders real pixels twice, once with a profiled body's curve and
//! once with the identity, and asserts the difference is the one a base curve
//! is for: midtones lifted, black still black, white still white.
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::EditGraph;
const SIZE: u32 = 16;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat RGGB frame at `level` out of 65535, carrying `curve`.
///
/// Every photosite the same value, so the demosaic result is a uniform grey
/// and the only thing that can move a pixel is the curve. The colour matrix is
/// the identity and the balance is neutral for the same reason: this test is
/// about one stage, and a real body's matrix would make every assertion below
/// a statement about that body instead.
fn flat_raw(level: u16, curve: BaseCurve) -> RawImage {
RawImage {
width: SIZE,
height: SIZE,
data: vec![level; (SIZE * SIZE) as usize],
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: u16::MAX,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: curve,
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
}
}
/// Render a neutral edit over a flat frame and return the centre pixel's red.
///
/// The centre rather than a corner: a demosaic has to invent its edges, and
/// the interpolated border of a 16×16 frame is not where anyone should be
/// reading a tone off.
fn rendered_level(ctx: &GpuContext, level: u16, curve: BaseCurve) -> u8 {
let raw = flat_raw(level, curve);
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic");
let shader = EditGraph::default_chain().compose();
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
pixels[centre as usize]
}
/// The Canon EOS 6D's curve, from the shipped profile database.
///
/// Looked up by name rather than written out, so this also asserts the thing
/// no other test can: that a curve travels from the YAML, through the body
/// match, onto the decoded image and into the uniform block that the shader
/// actually reads.
fn six_d() -> BaseCurve {
let curve = dr_decode::base_curve::for_body("Canon", "EOS 6D");
assert!(
!curve.is_identity(),
"the shipped database must have a curve for the EOS 6D"
);
curve
}
#[test]
fn a_profiled_body_renders_brighter_midtones_than_a_flat_one() {
// **The whole requirement, in one assertion.** A linear midtone renders
// roughly half a stop dark, which is the flat, lifeless look FR-DEV-3e
// exists to get away from. If the curve did not reach the shader — wrong
// slot, wrong flag, wrong stage — this is the only test that would fail.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
// 13% of full scale: roughly where a camera places middle grey, leaving
// about two and a half stops of highlight headroom above it.
let level = (0.13 * 65535.0) as u16;
let flat = rendered_level(&ctx, level, BaseCurve::IDENTITY);
let profiled = rendered_level(&ctx, level, six_d());
assert!(
profiled > flat + 8,
"the profile lifted middle grey from {flat} only to {profiled}"
);
}
#[test]
fn the_curve_leaves_black_black_and_white_white() {
// A base curve renders the range between the endpoints; it must not move
// the endpoints themselves. A curve that lifted black would put a grey
// veil over every night photograph, and one that pulled white down would
// make a correctly exposed frame look underexposed.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let curve = six_d();
assert_eq!(rendered_level(&ctx, 0, curve), 0, "black moved");
assert_eq!(rendered_level(&ctx, u16::MAX, curve), 255, "white moved");
}
#[test]
fn an_unprofiled_body_renders_exactly_as_it_did_before_profiles_existed() {
// The graceful fallback, asserted as a number rather than as a promise.
// With no curve the pipeline must still be a pass-through: black level
// out, white level in, sRGB encoding on the way to the screen and nothing
// else. "Never worse than today" is the one property this change was not
// allowed to trade away, and the way it would break is silently — a flag
// read from the wrong component would apply a curve nobody asked for.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
for level in [0u16, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
let scene = f32::from(level) / f32::from(u16::MAX);
let expected = (dr_types::Transfer::Srgb.encode(scene) * 255.0).round() as i32;
let got = i32::from(rendered_level(&ctx, level, BaseCurve::IDENTITY));
// Two 8-bit steps: the texture holding the demosaiced frame is
// `Rgba16Float`, so a value round-trips through eleven mantissa bits
// before it is encoded. That is well under one step at any level, and
// the tolerance is for the rounding either side of it rather than for
// the transform being approximate.
assert!(
(got - expected).abs() <= 2,
"raw {level} rendered as {got}, expected about {expected}"
);
}
}
#[test]
fn the_curve_is_monotone_through_the_whole_range() {
// The property the spline's tangent limiting exists to guarantee, checked
// where it actually matters: on the device, through the real uniform
// packing. A curve that dipped anywhere would put a dark band across a
// smooth gradient — a sky, most visibly — and it would read as a
// rendering fault rather than as a bad profile.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let curve = six_d();
let mut previous = 0u8;
for step in 0..=16u32 {
let level = (step * 65535 / 16) as u16;
let value = rendered_level(&ctx, level, curve);
assert!(
value >= previous,
"the curve fell from {previous} to {value} at raw level {level}"
);
previous = value;
}
}
+18 -11
View File
@@ -87,8 +87,7 @@ fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> { fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out)); let scale = graph.render_scale(source.size(), (out, out));
let detail = let detail = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key) pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render"); .expect("render");
@@ -413,7 +412,7 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
let pipelines = pass.cached_detail_pipelines(); let pipelines = pass.cached_detail_pipelines();
let allocations = pass.detail_allocations(); let allocations = pass.detail_allocations();
assert_eq!(pipelines, 2, "one per axis of the separable mask"); assert_eq!(pipelines, 2, "one per axis of the separable mask");
assert_eq!(allocations, 2, "the colour result, and one hand-off"); assert_eq!(allocations, 3, "the colour result, and the ping-pong pair");
assert_eq!(pass.detail_dispatches(), 2); assert_eq!(pass.detail_dispatches(), 2);
assert_eq!(pass.colour_dispatches(), 1); assert_eq!(pass.colour_dispatches(), 1);
@@ -453,13 +452,12 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
#[test] #[test]
fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() { fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
// The failure mode that the pass-through exists to prevent, proved on a // Proved on a device rather than argued about. With the radius finer than
// device rather than argued about. With the radius finer than a render // a render pixel the operation declines to sharpen — but it is still
// pixel the operation declines to sharpen — but it is still active, so the // active, so the fused pass has already been composed to hand on
// fused pass has already been composed to hand on unclipped linear values, // unclipped linear values, and something must still perform the output
// and something must still perform the output transform. An empty chain // transform. Since D19 that is the view pass, whatever the chain holds:
// here would not be a soft preview: it would be a hard error out of // the chain is empty and the frame is still whole.
// `render_detailed`, on the most ordinary develop view there is.
let Some(ctx) = ctx() else { return }; let Some(ctx) = ctx() else { return };
const SOURCE: u32 = 128; const SOURCE: u32 = 128;
const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame
@@ -471,7 +469,16 @@ fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
let mut pass = AdjustPass::new(&ctx); let mut pass = AdjustPass::new(&ctx);
let sharp = render(&mut pass, &graph, &source, RENDER); let sharp = render(&mut pass, &graph, &source, RENDER);
assert_eq!(pass.detail_dispatches(), 1, "one pass, and it only encodes"); assert_eq!(
pass.detail_dispatches(),
0,
"nothing to sharpen at this scale"
);
assert_eq!(
pass.view_dispatches(),
1,
"and the view pass finishes the frame"
);
// And what reaches the screen is the unsharpened picture, not a black // And what reaches the screen is the unsharpened picture, not a black
// frame, a linear one, or a guess. // frame, a linear one, or a guess.
+79
View File
@@ -0,0 +1,79 @@
//! Contrast, on a device, at the two ends of the tonal range.
//!
//! The descriptor tests check that the fragment says the right words; these
//! check what those words do to a pixel. Both failures here passed every
//! descriptor test for months, because each is a property of the arithmetic
//! at the extremes rather than of the shape of the code.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::operation::compose;
use dr_pipeline::ops;
const SIZE: u32 = 8;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat frame of one sRGB colour, contrast set to `amount`, rendered and
/// read back as the colour of one pixel.
fn render(ctx: &GpuContext, rgb: [u8; 3], amount: f32) -> [u8; 3] {
let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|_| [rgb[0], rgb[1], rgb[2], 255])
.collect();
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
let mut chain = ops::chain();
let op = chain
.iter_mut()
.find(|o| o.descriptor().id.0 == "contrast")
.expect("contrast is in the chain");
op.set_param(ParamId("contrast"), amount);
let shader = compose(&chain);
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
let pixels = adjust.export_pixels().expect("readback").0;
[pixels[0], pixels[1], pixels[2]]
}
/// **The pink-blacks bug.** A near-black pixel whose red and blue sit a count
/// above its green — what white-balanced sensor noise in a night shadow looks
/// like — must come out grey when contrast is reduced, not magenta.
///
/// The ratio form lifted it by a gain of well over a hundred, and a hundred
/// times a one-count cast is a saturated colour.
#[test]
fn reducing_contrast_lifts_a_black_to_grey_not_to_magenta() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let [r, g, b] = render(&ctx, [4, 1, 4], -50.0);
let spread = r.max(g).max(b) - r.min(g).min(b);
assert!(
g > 40,
"a black at half contrast should be lifted toward grey, got ({r}, {g}, {b})"
);
assert!(
spread <= 6,
"the lift must be neutral: ({r}, {g}, {b}) has a cast of {spread}"
);
}
/// **The pinned highlights.** A light tone, above twice middle grey, must not
/// be pulled down to the top of the curve by the smallest positive contrast.
#[test]
fn a_little_contrast_leaves_a_highlight_where_it_was() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let before = render(&ctx, [230, 230, 230], 0.0)[1];
let after = render(&ctx, [230, 230, 230], 10.0)[1];
assert!(
after >= before.saturating_sub(2),
"contrast +10 took a highlight from {before} to {after}"
);
}
+22 -10
View File
@@ -38,15 +38,17 @@ fn grey(ctx: &GpuContext) -> DemosaicedImage {
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload") DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
} }
/// A pass that sums the instance list into the red channel and writes the /// A pass that sums the instance list into the red channel and writes a
/// output. Deliberately trivial: the value on screen is then a direct readout /// linear intermediate, which the view pass then encodes (D19). Deliberately
/// of what arrived in the buffer. /// trivial: the value on screen is then a direct readout of what arrived in the
/// buffer, through the sRGB encode — the source is an 8-bit upload, so the
/// view transform is skipped for it and the encode is the only thing between.
fn summing_pass(storage: Vec<[f32; 4]>, structure: u64) -> ComposedDetailPass { fn summing_pass(storage: Vec<[f32; 4]>, structure: u64) -> ComposedDetailPass {
let source = " let source = "
@group(0) @binding(0) var source: texture_2d<f32>; @group(0) @binding(0) var source: texture_2d<f32>;
struct Params { detail_base: vec4<f32> } struct Params { detail_base: vec4<f32> }
@group(0) @binding(1) var<uniform> u: Params; @group(0) @binding(1) var<uniform> u: Params;
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>; @group(0) @binding(2) var output: texture_storage_2d<rgba16float, write>;
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>; @group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
@compute @workgroup_size(8, 8, 1) @compute @workgroup_size(8, 8, 1)
@@ -61,7 +63,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
for (var i = 0u; i < n; i = i + 1u) { for (var i = 0u; i < n; i = i + 1u) {
total = total + instances[i].x * f32(i + 1u); total = total + instances[i].x * f32(i + 1u);
} }
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(total, f32(n) / 255.0, 0.0, 1.0)); textureStore(output, vec2<i32>(gid.xy), vec4<f32>(total, f32(n) * 0.1, 0.0, 1.0));
} }
" "
.to_string(); .to_string();
@@ -73,13 +75,17 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0], uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0],
storage, storage,
radius: 0, radius: 0,
writes_output: true,
// Any distinct number: the hash is a cache key, and these tests are // Any distinct number: the hash is a cache key, and these tests are
// what decide whether two chains share a pipeline. // what decide whether two chains share a pipeline.
structure_hash: structure, structure_hash: structure,
} }
} }
/// A linear value as the view pass leaves it in the 8-bit output.
fn encoded(linear: f32) -> u8 {
(dr_types::Transfer::Srgb.encode(linear) * 255.0).round() as u8
}
fn render(pass: &mut AdjustPass, source: &DemosaicedImage, chain: &ComposedDetail) -> Vec<u8> { fn render(pass: &mut AdjustPass, source: &DemosaicedImage, chain: &ComposedDetail) -> Vec<u8> {
// The fused half has to be composed knowing a detail stage follows it, or // The fused half has to be composed knowing a detail stage follows it, or
// it encodes its own output and the chain would quantise twice — a mismatch // it encodes its own output and the chain would quantise twice — a mismatch
@@ -118,12 +124,15 @@ fn a_pass_reads_the_list_it_was_given() {
let pixels = render(&mut pass, &source, &chain); let pixels = render(&mut pass, &source, &chain);
let (red, green) = (pixels[0], pixels[1]); let (red, green) = (pixels[0], pixels[1]);
// 0.05·1 + 0.1·2 = 0.25, written straight to an rgba8 target. // 0.05·1 + 0.1·2 = 0.25.
assert!( assert!(
red.abs_diff((0.25 * 255.0) as u8) <= 1, red.abs_diff(encoded(0.25)) <= 1,
"the shader summed {red}, not the list it was handed" "the shader summed {red}, not the list it was handed"
); );
assert_eq!(green, 2, "arrayLength saw both entries"); assert!(
green.abs_diff(encoded(0.2)) <= 1,
"arrayLength saw both entries"
);
} }
/// A convolution declares no list and must still run: it is bound to the /// A convolution declares no list and must still run: it is bound to the
@@ -142,7 +151,10 @@ fn a_pass_with_no_list_still_runs() {
let pixels = render(&mut pass, &source, &chain); let pixels = render(&mut pass, &source, &chain);
assert_eq!(pixels[0], 0, "the placeholder is zeroed"); assert_eq!(pixels[0], 0, "the placeholder is zeroed");
assert_eq!(pixels[1], 1, "and is exactly one element long"); assert!(
pixels[1].abs_diff(encoded(0.1)) <= 1,
"and is exactly one element long"
);
} }
/// The property that makes placing the tenth spot as cheap as moving a slider: /// The property that makes placing the tenth spot as cheap as moving a slider:
+3 -5
View File
@@ -101,8 +101,7 @@ fn render(
let _ = ctx; let _ = ctx;
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out)); let scale = graph.render_scale(source.size(), (out, out));
let detail = let detail = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key) pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render"); .expect("render");
@@ -301,7 +300,7 @@ fn dragging_a_slider_recompiles_nothing_and_reallocates_nothing() {
let pipelines = pass.cached_detail_pipelines(); let pipelines = pass.cached_detail_pipelines();
let allocations = pass.detail_allocations(); let allocations = pass.detail_allocations();
assert_eq!(pipelines, 2, "one per pass of the separable blur"); assert_eq!(pipelines, 2, "one per pass of the separable blur");
assert_eq!(allocations, 2, "the colour result, and one hand-off"); assert_eq!(allocations, 3, "the colour result, and the ping-pong pair");
for radius in [0.06, 0.07, 0.08, 0.09] { for radius in [0.06, 0.07, 0.08, 0.09] {
graph.set_param(PROBE, RADIUS, radius); graph.set_param(PROBE, RADIUS, radius);
@@ -403,8 +402,7 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE)); let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
let detail = let detail = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
assert!(detail.is_empty()); assert!(detail.is_empty());
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0) pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
+264 -8
View File
@@ -15,10 +15,13 @@
//! model is checked against the reference, and the shader is checked against //! model is checked against the reference, and the shader is checked against
//! the CPU model. //! the CPU model.
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage}; use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_film::bake::{bake, Recipe}; use dr_film::bake::{bake, Recipe, Settings};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext}; use dr_gpu::{AdjustPass, Demosaicer, GpuContext, LabelField, MaskPass};
use dr_pipeline::ops::FilmTables; use dr_pipeline::mask::{MaskLayer, MaskSource};
use dr_pipeline::ops::film_sim;
use dr_pipeline::ops::film_sim::FORMAT_COUNT;
use dr_pipeline::ops::{FilmTables, PaperTables};
use dr_pipeline::EditGraph; use dr_pipeline::EditGraph;
const SIZE: u32 = 16; const SIZE: u32 = 16;
@@ -45,7 +48,6 @@ fn flat_raw(level: u16) -> RawImage {
// Off deliberately: a film replaces the camera's rendering, and // Off deliberately: a film replaces the camera's rendering, and
// leaving a curve here would test the suppression rather than the // leaving a curve here would test the suppression rather than the
// film. `dr-pipeline` asserts the suppression on the generated source. // film. `dr-pipeline` asserts the suppression on the generated source.
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -77,15 +79,31 @@ fn tables(baked: &dr_film::Baked) -> FilmTables {
/// split a grain that never left the CPU would look exactly like a passing /// split a grain that never left the CPU would look exactly like a passing
/// test suite. /// test suite.
fn tables_with_grain(baked: &dr_film::Baked, particles: [f32; 3]) -> FilmTables { fn tables_with_grain(baked: &dr_film::Baked, particles: [f32; 3]) -> FilmTables {
// The paper, when there is one, rides behind the film: its curve as one
// more row, its cube stacked after the film's.
let mut curves = baked.curves.clone();
let mut lut = baked.lut.clone();
let paper = baked.paper.as_ref().map(|p| {
curves.extend_from_slice(&p.curves);
lut.extend_from_slice(&p.lut);
PaperTables {
balance: p.balance,
log_min: p.log_min,
log_max: p.log_max,
density_max: p.density_max,
}
});
FilmTables { FilmTables {
exposure_matrix: baked.exposure_matrix, exposure_matrix: baked.exposure_matrix,
curves: baked.curves.clone(), curves,
push_stations: baked.push_stations.clone(),
curve_log_min: baked.curve_log_min, curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max, curve_log_max: baked.curve_log_max,
lut: baked.lut.clone(), lut,
density_max: baked.density_max, density_max: baked.density_max,
lut_size: baked.lut_size, lut_size: baked.lut_size,
grain_particles: particles, paper,
grain_particles: [particles; FORMAT_COUNT],
grain_density_max: [baked.density_max; 3], grain_density_max: [baked.density_max; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
} }
@@ -241,3 +259,241 @@ fn grain_reaches_the_shader_and_scales_with_the_pixel() {
"grain never reached the shader: the coarsest setting moved the pixel by {coarse_err}" "grain never reached the shader: the coarsest setting moved the pixel by {coarse_err}"
); );
} }
/// The same render, with the film's sliders set and mask layers laid over it.
///
/// Every layer is a `Regions` mask over a field splitting the frame down the
/// middle: region 0, the left half, at full weight, and the right half
/// untouched. Returns the left and right centre pixels, linear.
fn rendered_split(
ctx: &GpuContext,
level: u16,
tables: FilmTables,
global: Settings,
layers: Vec<MaskLayer>,
) -> ([f32; 3], [f32; 3]) {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(&flat_raw(level))
.expect("demosaic");
let mut graph = EditGraph::default_chain();
graph.set_film(Some(dr_pipeline::graph::Film {
stock: "under_test".to_string(),
print: None,
tables: tables.clone(),
}));
graph.set_param(film_sim::ID, film_sim::EXPOSURE, global.exposure_ev);
graph.set_param(film_sim::ID, film_sim::PUSH, global.push_stops);
graph.set_param(
film_sim::ID,
film_sim::PRINT_EXPOSURE,
global.print_exposure_ev,
);
for layer in layers {
graph.masks_mut().push(layer);
}
let shader = graph.compose();
let labels: Vec<u32> = (0..SIZE * SIZE)
.map(|i| u32::from(i % SIZE >= SIZE / 2))
.collect();
let field = LabelField::upload(ctx, &labels, SIZE, SIZE, 2).expect("label upload");
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks
.render(graph.masks(), Some(&field), None, None, SIZE, SIZE)
.expect("rasterise");
let mut adjust = AdjustPass::new(ctx);
adjust.set_film(Some(&tables));
adjust
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
.expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let at = |x: u32| {
let c = (((SIZE / 2) * SIZE + x) * 4) as usize;
[0, 1, 2].map(|i| srgb_to_linear(f32::from(pixels[c + i]) / 255.0))
};
(at(SIZE / 4), at(3 * SIZE / 4))
}
/// A layer over the left half holding these film offsets.
fn left_half(id: &str, offsets: &[(dr_pipeline::descriptor::ParamId, f32)]) -> MaskLayer {
let mut layer = MaskLayer::new(
id,
MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
},
);
for (param, v) in offsets {
layer.set_param(film_sim::ID.0, *param, *v);
}
layer
}
fn assert_close(got: [f32; 3], want: [f32; 3], what: &str) {
for c in 0..3 {
assert!(
(got[c] - want[c]).abs() < 0.02,
"{what}, channel {c}: GPU gave {got:?}, the model says {want:?}"
);
}
}
#[test]
fn a_print_renders_on_the_gpu_the_way_it_does_on_the_cpu_at_any_setting() {
// TRACES: FR-DEV-3f
// The print path — the film's lookup into the paper's log exposure, the
// enlarger added between, the paper's curve and its own lookup — is read
// from the same two textures as the film, at offsets. Every one of those
// offsets is a way to render a plausible print of the wrong thing.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_portra_400").expect("stock");
let paper = dr_film::default_print(film).expect("paper");
let baked = bake(&Recipe::new(film, Some(paper)));
for settings in [
Settings::default(),
Settings {
print_exposure_ev: -1.3,
..Settings::default()
},
Settings {
exposure_ev: 0.4,
print_exposure_ev: 0.8,
..Settings::default()
},
] {
for level in [6_000u16, 20_000] {
let input = f32::from(level) / f32::from(u16::MAX);
let (got, _) = rendered_split(&ctx, level, tables(&baked), settings, Vec::new());
assert_close(
got,
baked.apply_at([input; 3], &settings),
&format!("{settings:?} at {level}"),
);
}
}
}
#[test]
fn a_push_between_two_measured_processes_renders_as_the_model_does() {
// TRACES: FR-DEV-3f
// Double-X measures five processes; a push between two is a mix of two
// rows of the curve texture, found by searching the stations uniform.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_doublex").expect("stock");
let baked = bake(&Recipe::new(film, None));
assert!(baked.curve_rows > 2, "Double-X has a development series");
for push in [-0.8f32, 0.4, 1.3, 2.9] {
let settings = Settings {
push_stops: push,
..Settings::default()
};
let level = 12_000u16;
let input = f32::from(level) / f32::from(u16::MAX);
let (got, _) = rendered_split(&ctx, level, tables(&baked), settings, Vec::new());
assert_close(
got,
baked.apply_at([input; 3], &settings),
&format!("push {push}"),
);
}
}
#[test]
fn a_layer_develops_its_region_on_its_own_settings() {
// TRACES: FR-DEV-3f
// Offsets to the photograph's: print exposure +1 on a photograph at +0.5
// is +1.5 under the layer, and the rest of the print is untouched. Before
// film was blended as settings the layer's sliders moved and nothing
// happened, because the layer's copy of the node had no stock.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_portra_400").expect("stock");
let paper = dr_film::default_print(film).expect("paper");
let baked = bake(&Recipe::new(film, Some(paper)));
let level = 12_000u16;
let input = f32::from(level) / f32::from(u16::MAX);
let global = Settings {
print_exposure_ev: 0.5,
..Settings::default()
};
let layer = left_half(
"burn",
&[(film_sim::PRINT_EXPOSURE, 1.0), (film_sim::EXPOSURE, -0.5)],
);
let (left, right) = rendered_split(&ctx, level, tables(&baked), global, vec![layer]);
let under = Settings {
exposure_ev: -0.5,
print_exposure_ev: 1.5,
..Settings::default()
};
assert_close(left, baked.apply_at([input; 3], &under), "under the layer");
assert_close(right, baked.apply_at([input; 3], &global), "outside it");
assert!(
left[1] < right[1] - 0.01,
"the burn did not darken: {left:?} vs {right:?}"
);
}
#[test]
fn overlapping_layers_take_the_average_of_their_settings() {
// TRACES: FR-DEV-3f
// Three layers over the same pixels at full weight: the plain mean of what
// each asks for, and the global setting has no weight left. Summed, the
// offsets would be -2 stops of print exposure and +1 of exposure; the mean
// is (-1, -1, 0) / 3 and (0, 0, +1) / 3.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_portra_400").expect("stock");
let paper = dr_film::default_print(film).expect("paper");
let baked = bake(&Recipe::new(film, Some(paper)));
let level = 12_000u16;
let input = f32::from(level) / f32::from(u16::MAX);
let layers = vec![
left_half("a", &[(film_sim::PRINT_EXPOSURE, -1.0)]),
left_half("b", &[(film_sim::PRINT_EXPOSURE, -1.0)]),
left_half("c", &[(film_sim::EXPOSURE, 1.0)]),
];
let (left, right) = rendered_split(&ctx, level, tables(&baked), Settings::default(), layers);
let mean = Settings {
exposure_ev: 1.0 / 3.0,
print_exposure_ev: -2.0 / 3.0,
..Settings::default()
};
let summed = Settings {
exposure_ev: 1.0,
print_exposure_ev: -2.0,
..Settings::default()
};
let (m, s) = (
baked.apply_at([input; 3], &mean)[1],
baked.apply_at([input; 3], &summed)[1],
);
// Three times the tolerance the GPU is held to below, or a sum could pass
// for a mean.
assert!(
(m - s).abs() > 0.06,
"the mean and the sum render alike ({m} vs {s}), so this proves nothing"
);
assert_close(left, baked.apply_at([input; 3], &mean), "under all three");
assert_close(right, baked.apply([input; 3]), "outside them");
}
+141
View File
@@ -0,0 +1,141 @@
//! TRACES: FR-RAW-3
//! Hot and dead photosite repair, end to end on a device.
//!
//! Each test renders a frame twice — once with a defect, once without — and
//! compares the finished pixels. That is the only comparison that means
//! anything: the repair happens on the mosaic, and what a photographer would
//! see of a defect it missed is the coloured cross the demosaic makes of it.
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::EditGraph;
const SIZE: u32 = 36;
const WHITE: u16 = 4095;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat frame at `level`, with `set` applied to its photosites.
fn frame(pattern: CfaPattern, level: u16, set: &[(u32, u32, u16)]) -> RawImage {
let mut data = vec![level; (SIZE * SIZE) as usize];
for &(x, y, v) in set {
data[(y * SIZE + x) as usize] = v;
}
RawImage {
width: SIZE,
height: SIZE,
data,
cfa_pattern: pattern,
black_level: [0; 4],
white_level: WHITE,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
}
}
fn render(ctx: &GpuContext, raw: &RawImage) -> Vec<u8> {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(raw)
.expect("demosaic");
let shader = EditGraph::default_chain().compose();
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
adjust.export_pixels().expect("readback").0
}
/// The largest channel difference between two renders.
fn worst(a: &[u8], b: &[u8]) -> u8 {
a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap()
}
const MIDDLE: u32 = SIZE / 2;
/// **The feature.** A photosite at white in a dark frame — a hot pixel in a
/// night sky — leaves no trace in the rendered picture.
#[test]
fn a_hot_photosite_in_a_dark_frame_is_invisible() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let clean = render(&ctx, &frame(CfaPattern::Rggb, 40, &[]));
for (x, y) in [
(MIDDLE, MIDDLE),
(MIDDLE + 1, MIDDLE),
(MIDDLE + 1, MIDDLE + 1),
] {
let hot = render(&ctx, &frame(CfaPattern::Rggb, 40, &[(x, y, WHITE)]));
let diff = worst(&clean, &hot);
assert!(
diff <= 1,
"a hot photosite at ({x}, {y}) still shows, by {diff}"
);
}
}
/// The same for one stuck dark in a lit area.
#[test]
fn a_dead_photosite_in_a_lit_frame_is_invisible() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let clean = render(&ctx, &frame(CfaPattern::Rggb, 1600, &[]));
let dead = render(&ctx, &frame(CfaPattern::Rggb, 1600, &[(MIDDLE, MIDDLE, 0)]));
let diff = worst(&clean, &dead);
assert!(diff <= 1, "a dead photosite still shows, by {diff}");
}
/// **What it must not eat.** A point of real light lands on a patch of
/// photosites, not one — so a 3×3 highlight survives, even at its brightest.
#[test]
fn a_small_real_highlight_survives() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut star = Vec::new();
for dy in 0..3 {
for dx in 0..3 {
star.push((MIDDLE - 1 + dx, MIDDLE - 1 + dy, WHITE));
}
}
let clean = render(&ctx, &frame(CfaPattern::Rggb, 40, &[]));
let lit = render(&ctx, &frame(CfaPattern::Rggb, 40, &star));
let at = ((MIDDLE * SIZE + MIDDLE) * 4 + 1) as usize;
assert!(
lit[at] > clean[at] + 100,
"the highlight was repaired away: {} against a background of {}",
lit[at],
clean[at]
);
}
/// The Fujifilm path goes through the same repair, with its own tile.
#[test]
fn a_hot_photosite_on_x_trans_is_invisible() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let clean = render(&ctx, &frame(CfaPattern::XTrans, 40, &[]));
let hot = render(
&ctx,
&frame(CfaPattern::XTrans, 40, &[(MIDDLE, MIDDLE, WHITE)]),
);
let diff = worst(&clean, &hot);
assert!(diff <= 1, "a hot X-Trans photosite still shows, by {diff}");
}
+73
View File
@@ -1135,3 +1135,76 @@ fn two_shown_masks_are_drawn_each_in_its_own_colour() {
"between them, alpha shows black: ({r}, {g}, {b})" "between them, alpha shows black: ({r}, {g}, {b})"
); );
} }
/// Render a mid-grey-and-shadows frame through `chain` and `stack`.
fn render_chain(
ctx: &GpuContext,
chain: &[Box<dyn dr_pipeline::operation::Operation>],
stack: &MaskStack,
field: Option<&LabelField>,
) -> Vec<u8> {
// A ramp, so both ends of the tonal range are in the comparison.
let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|i| {
let v = ((i % SIZE) * 255 / (SIZE - 1)) as u8;
[v, v / 2, v, 255]
})
.collect();
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
let shader = compose_full(
chain,
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks
.render(stack, field, None, None, SIZE, SIZE)
.expect("rasterise");
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
.expect("render");
adjust.export_pixels().expect("readback").0
}
fn contrast_chain(v: f32) -> Vec<Box<dyn dr_pipeline::operation::Operation>> {
let mut chain = ops::chain();
chain
.iter_mut()
.find(|o| o.descriptor().id.0 == "contrast")
.expect("contrast")
.set_param(ParamId("contrast"), v);
chain
}
/// A layer's setting is an offset to the global one, applied once: global
/// −30 with a whole-frame layer at −20 is exactly global −50 — not −30 and
/// then −20 again on the result, which is what a layer used to do.
#[test]
fn a_whole_frame_layer_adds_its_setting_to_the_global_one() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut layer = MaskLayer::new("m1", whole_frame());
layer.set_param("contrast", ParamId("contrast"), -20.0);
let mut stack = MaskStack::new();
stack.push(layer);
let field = split_field(&ctx);
let offset = render_chain(&ctx, &contrast_chain(-30.0), &stack, Some(&field));
let direct = render_chain(&ctx, &contrast_chain(-50.0), &MaskStack::new(), None);
let worst = offset
.iter()
.zip(&direct)
.map(|(a, b)| a.abs_diff(*b))
.max()
.unwrap();
assert!(
worst <= 1,
"layer offset differs from the summed setting by {worst}"
);
}
+13 -21
View File
@@ -109,8 +109,7 @@ fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> {
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> { fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out)); let scale = graph.render_scale(source.size(), (out, out));
let detail = let detail = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key) pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render"); .expect("render");
@@ -683,28 +682,21 @@ fn texture_contributes_nothing_where_its_scale_does_not_exist() {
// `render_masked`, and was rejected for handing a linear-working shader // `render_masked`, and was rejected for handing a linear-working shader
// to the plain path — so texture alone on a thumbnail did not render. // to the plain path — so texture alone on a thumbnail did not render.
// //
// The seam was closed where that note said it would have to be, at the // The seam was closed at the composition boundary, and closed again,
// composition boundary: `compose_detail` now emits a bodyless // more simply, by D19: no detail pass encodes any more, the fused pass's
// `detail/resolve` pass in exactly this case, which reads only the pixel // view pass performs the output transform whatever the chain holds, and
// it writes and performs the output transform the fused pass declined to // so the empty chain is a whole render. That is the honest description of
// do. So the chain is no longer empty — it carries precisely the one pass // "a two-pixel surface structure is not present in a 128-pixel
// that finishes the render and no kernel at all, which is the honest // rendering".
// description of "a two-pixel surface structure is not present in a
// 128-pixel rendering".
let scale = graph.render_scale(source.size(), (128, 128)); let scale = graph.render_scale(source.size(), (128, 128));
let composed = let composed = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb); assert!(
assert_eq!( composed.is_empty(),
composed.len(),
1,
"the chain must carry the resolve pass and nothing else"
);
assert_eq!(composed.passes[0].label, "detail/resolve");
assert_eq!(
composed.radius(),
0,
"texture claimed a kernel it cannot draw" "texture claimed a kernel it cannot draw"
); );
let mut pass = AdjustPass::new(&ctx);
render(&mut pass, &graph, &source, 128);
assert_eq!(pass.view_dispatches(), 1, "the view pass still finishes it");
// With clarity on as well the edit is renderable again, and the dispatch // With clarity on as well the edit is renderable again, and the dispatch
// count says what the assertion above says: two passes, not four. Texture // count says what the assertion above says: two passes, not four. Texture
+1 -2
View File
@@ -73,8 +73,7 @@ fn render_at(
scale: RenderScale, scale: RenderScale,
) -> Vec<u8> { ) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let detail = let detail = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key) pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
.expect("render"); .expect("render");
+196
View File
@@ -0,0 +1,196 @@
//! TRACES: FR-DEV-2 | FR-DEV-3j
//! Scene-referred until the view transform (D19, ARCH §6.14), on a device.
//!
//! The rule is about every operation between the camera matrix and the view
//! transform, so this runs each of them over a ramp that reaches sixteen
//! times sensor saturation and asserts the two things a clip or an early
//! encode would break: the output still increases with the input, and values
//! above 1.0 still differ from one another.
//!
//! A clip above 1.0 cannot be seen through an 8-bit display encode on its
//! own, so each operation is wrapped: a gain of sixteen ahead of it puts the
//! ramp into the range the rule is about, a gain of one sixty-fourth after it
//! brings the result back under 1.0 — with two stops to spare, for the
//! operations that brighten — and an identity in the view transform's
//! place stops the sigmoid compressing what is being measured. A fragment
//! that clamps, or encodes and decodes through a clamped range, flattens the
//! top of the ramp, and the last few steps come out equal.
//!
//! The view stage and the detail stage are excluded. The view transform and
//! film simulation clip into a display range because that is their job, and
//! a neighbourhood operation is a pass of its own that a flat frame cannot
//! exercise.
use std::sync::Arc;
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind};
use dr_pipeline::operation::{Operation, Stage, Uniform};
const SIZE: u32 = 16;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A gain, as a scene-stage operation, or an identity in the view stage.
struct Probe {
id: &'static str,
gain: f32,
stage: Stage,
}
impl Operation for Probe {
fn descriptor(&self) -> Arc<OpDescriptor> {
Arc::new(OpDescriptor {
id: OpId(self.id),
label: LocalizedKey(self.id),
params: Vec::new(),
attributes: vec![Attribute::Tone],
})
}
fn set_param(&mut self, _: ParamId, _: f32) {}
fn param(&self, _: ParamId) -> f32 {
0.0
}
fn is_active(&self) -> bool {
true
}
fn stage(&self) -> Stage {
self.stage
}
/// The identity view claims the view transform's place: while it is
/// active the composer emits it rather than the sigmoid.
fn renders(&self) -> bool {
self.stage == Stage::View
}
fn wgsl_body(&self) -> String {
"c = c * gain;".into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![Uniform {
name: "gain",
value: self.gain,
}]
}
}
fn probe(id: &'static str, gain: f32, stage: Stage) -> Box<dyn Operation> {
Box::new(Probe { id, gain, stage })
}
/// A flat frame at `level` of sensor saturation, identity matrix, neutral
/// balance.
fn flat(ctx: &GpuContext, level: f32) -> dr_gpu::DemosaicedImage {
let raw = RawImage {
width: SIZE,
height: SIZE,
data: vec![(level * f32::from(u16::MAX)).round() as u16; (SIZE * SIZE) as usize],
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: u16::MAX,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
};
Demosaicer::new(ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic")
}
/// Every parameter moved off its default, a third of the way toward its
/// maximum — or toward its minimum where the default is the maximum.
///
/// The tone curve is the exception, because its neutral is a relationship:
/// its parameters are point coordinates, and moving every x and y the same
/// fraction leaves the points on the diagonal. It gets a lifted midpoint on
/// the master and on the red curve instead — the two helpers that clamped.
fn non_neutral(op: &mut dyn Operation) {
use dr_pipeline::ops::curve::{coordinate, Axis, Channel};
if op.descriptor().id == dr_pipeline::ops::curve::ID {
op.set_param(coordinate(Channel::Master, 2, Axis::Y), 0.65);
op.set_param(coordinate(Channel::Red, 2, Axis::Y), 0.6);
return;
}
for p in &op.descriptor().params {
if let ParamKind::Scalar { min, max, .. } = p.kind {
let toward = if p.default < max { max } else { min };
op.set_param(p.id, p.default + (toward - p.default) / 3.0);
}
}
}
/// The ramp, as scene values after the sixteenfold gain: 0.4 to 16.
///
/// Kept below 1.0 at the sensor, and away from its last 1.5%, because the
/// prologue's highlight desaturation fades a photosite toward neutral there —
/// a sensor fact, not an operation's, and flat grey is neutral already.
const LEVELS: [f32; 8] = [0.025, 0.05, 0.1, 0.2, 0.4, 0.6, 0.8, 0.95];
#[test]
fn scene_referred_until_the_view() {
// TRACES: FR-DEV-2 | FR-DEV-3j
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let sources: Vec<_> = LEVELS.iter().map(|&l| flat(&ctx, l)).collect();
let mut adjust = AdjustPass::new(&ctx);
let mut checked = 0;
for mut op in dr_pipeline::ops::chain() {
if op.detail().is_some() || op.stage() == Stage::View {
continue;
}
let id = op.descriptor().id.0;
non_neutral(op.as_mut());
assert!(op.is_active(), "{id}: the edit above left it neutral");
let ops = vec![
probe("probe_up", 16.0, Stage::Scene),
op,
probe("probe_down", 1.0 / 64.0, Stage::Scene),
probe("probe_view", 1.0, Stage::View),
];
let shader = dr_pipeline::compose(&ops);
assert!(
!shader.source.contains("view_sigmoid"),
"the identity must take the view transform's place"
);
let mut out = Vec::new();
for source in &sources {
adjust.render(source, &shader, SIZE, SIZE).expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let centre = (((SIZE / 2) * SIZE + SIZE / 2) * 4) as usize;
out.push([pixels[centre], pixels[centre + 1], pixels[centre + 2]]);
}
for channel in 0..3 {
let ramp: Vec<u8> = out.iter().map(|p| p[channel]).collect();
assert!(
ramp.windows(2).all(|w| w[1] >= w[0]),
"{id} is not monotone in channel {channel}: {ramp:?}"
);
// The top three levels are scene 9.6, 12.8 and 15.2: all far
// above 1.0, and a clip anywhere below them makes them equal.
let top = &ramp[LEVELS.len() - 3..];
assert!(
top[0] < top[1] && top[1] < top[2],
"{id} flattens values above 1.0 in channel {channel}: {ramp:?}"
);
}
checked += 1;
}
assert!(checked >= 10, "only {checked} operations were checked");
}
+208
View File
@@ -0,0 +1,208 @@
//! TRACES: FR-DSP-2 | NFR-RES-2
//! A photograph larger than one texture, developed from windows of it.
//!
//! The claim under test is that the window is invisible: a frame rendered a
//! tile at a time, each tile from only the part of the source it reads, is the
//! frame rendered whole. `dr-pipeline` can check the plan — the tiles cover
//! the frame once, each is grown by the reach — but not that the shader's
//! mapping into a window lands on the texel the whole texture would have
//! given, which only a device answers.
//!
//! The frames here are small and the "device limit" is a number passed in,
//! so the tiling is exercised on any adapter, including one whose real limit
//! a test image could never approach.
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::descriptor::{OpId, ParamId};
use dr_pipeline::framing::ANGLE;
use dr_pipeline::{tiles, Affects, EditGraph};
use dr_types::ColourSpace;
fn ctx() -> Option<GpuContext> {
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
/// A linear RGB frame with detail at every scale: a slow gradient for the
/// tone controls and a hash for the kernels, so a tile that read one pixel
/// off would show.
fn linear_frame(w: u32, h: u32, noise: bool) -> RawImage {
let mut data = Vec::with_capacity((w * h * 3) as usize);
for y in 0..h {
for x in 0..w {
let base = 4000.0 + 30000.0 * (x as f32 / w as f32) + 12000.0 * (y as f32 / h as f32);
let hash = if noise {
((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 8000) as f32
} else {
0.0
};
for c in 0..3 {
data.push((base * (0.7 + 0.15 * c as f32) + hash) as u16);
}
}
}
RawImage {
width: w,
height: h,
data,
cfa_pattern: CfaPattern::Unknown,
black_level: [512; 4],
white_level: 65535,
wb_coeffs: [2.0, 1.0, 1.5, 1.0],
color_matrix: Some([1.6, -0.5, -0.1, -0.2, 1.4, -0.2, 0.0, -0.4, 1.4]),
samples_per_pixel: 3,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: w,
height: h,
},
}
}
/// Render `graph` over `source` at `size` and read it back.
fn render(
pass: &mut AdjustPass,
graph: &EditGraph,
source: &DemosaicedImage,
size: (u32, u32),
) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let detail = graph.compose_detail(source.size(), size);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, size.0, size.1, None, &detail, key)
.expect("render");
pass.export_pixels().expect("readback").0
}
/// The frame at full resolution, a tile at a time, each from its own window.
fn render_tiled(
ctx: &GpuContext,
pass: &mut AdjustPass,
graph: &mut EditGraph,
raw: &RawImage,
max_edge: u32,
) -> (Vec<u8>, usize) {
let frame = (raw.crop.width, raw.crop.height);
let out = graph.output_size(frame.0, frame.1);
let reach = graph.compose_detail(frame, out).reach();
let plan = tiles::plan(out, max_edge, reach).expect("a plan");
let mut pixels = vec![0u8; (out.0 * out.1 * 4) as usize];
for t in &plan {
graph.framing_mut().set_view(t.view(out));
let r = graph.source_region(frame, 0);
let x0 = (r.x * frame.0 as f32).floor() as u32;
let y0 = (r.y * frame.1 as f32).floor() as u32;
let x1 = ((r.x + r.width) * frame.0 as f32).ceil() as u32;
let y1 = ((r.y + r.height) * frame.1 as f32).ceil() as u32;
let window = DemosaicedImage::linear_rgb16_window(ctx, raw, [x0, y0, x1 - x0, y1 - y0], 1)
.expect("window");
assert_eq!(window.size(), frame, "a window measures the frame");
let tile = render(pass, graph, &window, (t.grown[2], t.grown[3]));
let (ox, oy) = t.keep_offset();
for row in 0..t.keep[3] {
let src = (((oy + row) * t.grown[2] + ox) * 4) as usize;
let dst = (((t.keep[1] + row) * out.0 + t.keep[0]) * 4) as usize;
let n = (t.keep[2] * 4) as usize;
pixels[dst..dst + n].copy_from_slice(&tile[src..src + n]);
}
}
graph
.framing_mut()
.set_view(dr_pipeline::CropRect::default());
(pixels, plan.len())
}
fn largest_difference(a: &[u8], b: &[u8]) -> u8 {
a.iter()
.zip(b)
.map(|(x, y)| x.abs_diff(*y))
.max()
.unwrap_or(0)
}
#[test]
fn tiles_of_windows_are_the_whole_frame() {
// Point operations only, unrotated: every output pixel is an exact load
// of one source texel, so the tiled frame has to be the whole one to
// the bit.
let Some(ctx) = ctx() else { return };
let raw = linear_frame(200, 120, true);
let mut graph = EditGraph::default_chain();
graph.set_param(OpId("exposure"), ParamId("exposure"), 0.7);
let mut pass = AdjustPass::new(&ctx);
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
assert!(whole.is_whole());
let reference = render(&mut pass, &graph, &whole, (200, 120));
let (tiled, n) = render_tiled(&ctx, &mut pass, &mut graph, &raw, 64);
assert!(n > 4, "the frame should have been cut, got {n} tile(s)");
assert_eq!(largest_difference(&reference, &tiled), 0);
}
#[test]
fn a_straightened_frame_with_clarity_tiles_without_seams() {
// The hard case: a free angle samples between texels, and clarity reads
// a wide neighbourhood on a reduced grid. The halo and the grid
// alignment are what keep the tiles' edges out of the picture; a code
// value of rounding is all that may differ.
let Some(ctx) = ctx() else { return };
let raw = linear_frame(320, 208, true);
let mut graph = EditGraph::default_chain();
graph.set_param(OpId("clarity"), ParamId("amount"), 60.0);
graph.framing_mut().set_param(ANGLE, 3.0);
let mut pass = AdjustPass::new(&ctx);
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
let out = graph.output_size(320, 208);
let reference = render(&mut pass, &graph, &whole, out);
let (tiled, n) = render_tiled(&ctx, &mut pass, &mut graph, &raw, 160);
assert!(n > 1, "the frame should have been cut, got {n} tile(s)");
let worst = largest_difference(&reference, &tiled);
assert!(
worst <= 1,
"tiles differ from the whole frame by {worst} code values"
);
}
#[test]
fn a_reduced_copy_stands_for_the_whole_frame() {
// The canvas at fit renders from a copy reduced to fit the device. It
// must measure the photograph, not itself, or a crop drawn on it lands
// somewhere else in the export; and rendered small it must look like the
// full frame rendered small.
let Some(ctx) = ctx() else { return };
let raw = linear_frame(400, 240, false);
let mut graph = EditGraph::default_chain();
graph.set_crop(dr_pipeline::CropRect {
x: 0.25,
y: 0.1,
width: 0.5,
height: 0.6,
});
let mut pass = AdjustPass::new(&ctx);
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
let reduced = DemosaicedImage::linear_rgb16_window(&ctx, &raw, [0, 0, 400, 240], 3).unwrap();
assert_eq!(reduced.size(), (400, 240));
assert_eq!(reduced.texture_size(), (134, 80));
assert!(!reduced.is_whole());
let size = (50, 36);
let a = render(&mut pass, &graph, &whole, size);
let b = render(&mut pass, &graph, &reduced, size);
let worst = largest_difference(&a, &b);
assert!(
worst <= 3,
"the reduced copy renders {worst} code values away"
);
}
+1 -1
View File
@@ -72,7 +72,7 @@ fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, ou
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let (w, h) = graph.output_size(source.size().0, source.size().1); let (w, h) = graph.output_size(source.size().0, source.size().1);
let (w, h) = (w.min(out), h.min(out)); let (w, h) = (w.min(out), h.min(out));
let detail = graph.compose_detail_for(source.size(), (w, h), ColourSpace::Srgb); let detail = graph.compose_detail(source.size(), (w, h));
let key = graph.invalidation().through(Affects::Colour); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, w, h, None, &detail, key) pass.render_detailed(source, &shader, w, h, None, &detail, key)
.expect("render"); .expect("render");
+178
View File
@@ -0,0 +1,178 @@
//! TRACES: FR-DEV-3j | FR-DEV-2
//! The view transform, end to end on a device.
//!
//! `dr-pipeline` checks the curve on the CPU and that the composer emits it in
//! the right place. Neither would notice a shader that disagreed with the CPU
//! reference, or a clamp somewhere upstream that made two highlights the same
//! number before the curve ever saw them — which is exactly what the retired
//! base curve did, and why D19 exists. So this renders real pixels.
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::view::Sigmoid;
use dr_pipeline::EditGraph;
const SIZE: u32 = 16;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat RGGB frame at `level` out of 65535, with an identity matrix and a
/// neutral balance, so the only things that move a pixel are the edit and the
/// view transform.
fn flat_raw(level: u16) -> RawImage {
RawImage {
width: SIZE,
height: SIZE,
data: vec![level; (SIZE * SIZE) as usize],
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: u16::MAX,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
}
}
/// Render `graph` over a flat frame and return the centre pixel's red.
///
/// The centre rather than a corner: a demosaic has to invent its edges.
fn rendered(ctx: &GpuContext, level: u16, graph: &EditGraph) -> u8 {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(&flat_raw(level))
.expect("demosaic");
let shader = graph.compose();
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
pixels[centre as usize]
}
#[test]
fn the_shader_agrees_with_the_cpu_reference() {
// TRACES: FR-DEV-3j
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let curve = Sigmoid::default_curve();
let graph = EditGraph::default_chain();
for level in [0u16, 500, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
let scene = f32::from(level) / f32::from(u16::MAX);
let display = curve.channel(scene).min(1.0);
let expected = (dr_types::Transfer::Srgb.encode(display) * 255.0).round() as i32;
let got = i32::from(rendered(&ctx, level, &graph));
// Two 8-bit steps, for the `Rgba16Float` intermediate and the
// rounding either side of the encode.
assert!(
(got - expected).abs() <= 2,
"raw {level} rendered as {got}, expected about {expected}"
);
}
}
#[test]
fn highlights_above_one_stay_distinct() {
// TRACES: FR-DEV-2 | FR-DEV-3j
// The failure D19 names first. Two stops of exposure put these two
// frames at 1.0 and 1.5 of sensor saturation. The base curve was flat
// past 1.0, so both rendered as the same white; the view transform's
// shoulder still separates them.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let mut graph = EditGraph::default_chain();
graph.set_param(
dr_pipeline::ops::exposure::ID,
dr_pipeline::ops::exposure::EXPOSURE,
2.0,
);
let lower = rendered(&ctx, u16::MAX / 4, &graph);
let upper = rendered(&ctx, (u16::MAX / 8) * 3, &graph);
assert!(
upper > lower,
"scene 1.0 rendered {lower} and scene 1.5 rendered {upper}"
);
assert!(upper < 255, "scene 1.5 is below the default white point");
}
#[test]
fn the_rendering_is_monotone_through_the_whole_range() {
// TRACES: FR-DEV-3j
// A dip anywhere puts a dark band across a smooth gradient — a sky, most
// visibly.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let graph = EditGraph::default_chain();
let mut last = 0u8;
for step in 0..=32u32 {
let level = (step * u32::from(u16::MAX) / 32) as u16;
let got = rendered(&ctx, level, &graph);
assert!(got >= last, "raw {level} rendered {got}, below {last}");
last = got;
}
}
/// Render `graph` over a flat frame through `render_detailed`, the path every
/// frontend takes, and return the centre pixel's red.
fn rendered_detailed(ctx: &GpuContext, level: u16, graph: &EditGraph) -> (u8, AdjustPass) {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(&flat_raw(level))
.expect("demosaic");
let shader = graph.compose_for(dr_types::ColourSpace::Srgb);
let detail = graph.compose_detail(source.size(), (SIZE, SIZE));
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
let mut adjust = AdjustPass::new(ctx);
adjust
.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, key)
.expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
(pixels[centre as usize], adjust)
}
#[test]
fn a_detail_stage_renders_through_the_view_pass_unchanged() {
// TRACES: FR-DEV-3j | FR-DEV-2
// With a detail stage the view transform is a dispatch of its own after
// it (D19). Sharpening a flat field changes nothing, so the same frame
// with and without it must render the same: the view pass read the detail
// stage's result, applied the view transform once, and encoded once.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let plain = rendered(&ctx, 8_520, &EditGraph::default_chain());
let mut sharpened = EditGraph::default_chain();
let id = dr_pipeline::ops::capture_sharpen::ID;
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::AMOUNT, 100.0);
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::RADIUS, 1.0);
let (detailed, pass) = rendered_detailed(&ctx, 8_520, &sharpened);
assert!(
pass.detail_dispatches() > 0,
"the premise: a detail stage ran"
);
assert_eq!(pass.view_dispatches(), 1);
assert!(
detailed.abs_diff(plain) <= 1,
"with a detail stage {detailed}, without {plain}"
);
}
+142 -6
View File
@@ -12,6 +12,11 @@
//! best-connected frame; rotations chained along it. //! best-connected frame; rotations chained along it.
//! 5. Bundle adjustment over every link's inliers (`bundle`). //! 5. Bundle adjustment over every link's inliers (`bundle`).
//! //!
//! Steps 1 and 2 are [`match_pairs`] and most of the time; 3 to 5 are
//! [`solve`], which takes a subset of the frames. Leaving a frame out is
//! then a solve over the pairs already measured — the same links, not a
//! fresh RANSAC whose seeds would move with the frames' positions.
//!
//! What it refuses to do is guess. A frame the tree does not reach is //! What it refuses to do is guess. A frame the tree does not reach is
//! reported by index with the reason (FR-MRG-5) and left out of the //! reported by index with the reason (FR-MRG-5) and left out of the
//! cameras; the caller decides whether a set with a hole is worth //! cameras; the caller decides whether a set with a hole is worth
@@ -125,13 +130,45 @@ impl Alignment {
} }
} }
/// Align a set of frames from their features. /// Every pair of a set measured: steps 1 and 2, the expensive part, kept
/// so that a solve over a subset reuses it.
#[derive(Debug, Clone, PartialEq)]
pub struct Pairs {
/// Each frame's long edge, for the focal length's clamp.
long_edges: Vec<f64>,
/// Pairs with enough matches to try a geometry, whether or not it held.
matched: Vec<(usize, usize)>,
links: Vec<Link>,
/// Every link's inliers, in pixels, centred.
observations: Vec<Observation>,
}
impl Pairs {
/// How many frames were measured.
pub fn len(&self) -> usize {
self.long_edges.len()
}
pub fn is_empty(&self) -> bool {
self.long_edges.is_empty()
}
}
/// Align a set of frames from their features: [`match_pairs`], then
/// [`solve`] over all of them.
/// ///
/// Every `Features` must be in its own frame's pixel coordinates with the /// Every `Features` must be in its own frame's pixel coordinates with the
/// image size filled in; points are centred on the image centre here. The /// image size filled in; points are centred on the image centre here. The
/// frames must all come from the same lens at the same focal length, which /// frames must all come from the same lens at the same focal length, which
/// is the panorama assumption and not checked — the caller has the EXIF. /// is the panorama assumption and not checked — the caller has the EXIF.
pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, PanoError> { pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, PanoError> {
let pairs = match_pairs(frames, opts)?;
solve(&pairs, &vec![true; frames.len()], opts)
}
/// Steps 1 and 2: every pair matched, and a robust homography for each
/// pair with enough matches.
pub fn match_pairs(frames: &[Features], opts: &AlignOptions) -> Result<Pairs, PanoError> {
let n = frames.len(); let n = frames.len();
if n < 2 { if n < 2 {
return Err(PanoError::Input( return Err(PanoError::Input(
@@ -156,7 +193,7 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
// 1 + 2: every pair. // 1 + 2: every pair.
let mut links = Vec::new(); let mut links = Vec::new();
let mut observations: Vec<Observation> = Vec::new(); let mut observations: Vec<Observation> = Vec::new();
let mut matched_any = vec![false; n]; let mut matched = Vec::new();
let t_match = std::time::Instant::now(); let t_match = std::time::Instant::now();
for i in 0..n { for i in 0..n {
for j in i + 1..n { for j in i + 1..n {
@@ -165,8 +202,7 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
if matches.len() < 4 { if matches.len() < 4 {
continue; continue;
} }
matched_any[i] = true; matched.push((i, j));
matched_any[j] = true;
let pairs: Vec<((f64, f64), (f64, f64))> = matches let pairs: Vec<((f64, f64), (f64, f64))> = matches
.iter() .iter()
.map(|m| { .map(|m| {
@@ -214,6 +250,69 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
} }
log::debug!("matching and pairwise geometry in {:?}", t_match.elapsed()); log::debug!("matching and pairwise geometry in {:?}", t_match.elapsed());
Ok(Pairs {
long_edges: frames
.iter()
.map(|f| f.width.max(f.height) as f64)
.collect(),
matched,
links,
observations,
})
}
/// Steps 3 to 5 over the frames `keep` marks, from pairs already measured.
///
/// The result is indexed by the kept frames in order: its frame `k` is the
/// `k`-th frame `keep` marks. Only pairs whose frames are both kept take
/// part, so a frame whose only overlap was with one left out is reported
/// as unaligned, as it would be had it never been measured with it.
pub fn solve(pairs: &Pairs, keep: &[bool], opts: &AlignOptions) -> Result<Alignment, PanoError> {
if keep.len() != pairs.len() {
return Err(PanoError::Input(format!(
"{} flags for {} frames",
keep.len(),
pairs.len()
)));
}
// Input index to the solve's.
let mut slot = vec![None; keep.len()];
let mut n = 0usize;
for (k, &kept) in keep.iter().enumerate() {
if kept {
slot[k] = Some(n);
n += 1;
}
}
if n < 2 {
return Err(PanoError::Input(
"a panorama needs at least two frames".into(),
));
}
let both = |i: usize, j: usize| Some((slot[i]?, slot[j]?));
let mut matched_any = vec![false; n];
for &(i, j) in &pairs.matched {
if let Some((i, j)) = both(i, j) {
matched_any[i] = true;
matched_any[j] = true;
}
}
let links: Vec<Link> = pairs
.links
.iter()
.filter_map(|l| {
let (i, j) = both(l.i, l.j)?;
Some(Link { i, j, ..l.clone() })
})
.collect();
let observations: Vec<Observation> = pairs
.observations
.iter()
.filter_map(|o| {
let (i, j) = both(o.i, o.j)?;
Some(Observation { i, j, ..*o })
})
.collect();
// 3: the focal length. // 3: the focal length.
let mut estimates: Vec<f64> = links let mut estimates: Vec<f64> = links
@@ -221,9 +320,12 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
.filter_map(|l| homography::focal_from_homography(&l.h)) .filter_map(|l| homography::focal_from_homography(&l.h))
.filter(|f| f.is_finite() && *f > 0.0) .filter(|f| f.is_finite() && *f > 0.0)
.collect(); .collect();
let longest = frames let longest = pairs
.long_edges
.iter() .iter()
.map(|f| f.width.max(f.height) as f64) .zip(keep)
.filter(|(_, &kept)| kept)
.map(|(&e, _)| e)
.fold(0.0, f64::max); .fold(0.0, f64::max);
let focal = if !estimates.is_empty() { let focal = if !estimates.is_empty() {
estimates.sort_by(f64::total_cmp); estimates.sort_by(f64::total_cmp);
@@ -448,6 +550,40 @@ mod tests {
assert!(out.rotations[..3].iter().all(Option::is_some)); assert!(out.rotations[..3].iter().all(Option::is_some));
} }
#[test]
fn a_frame_left_out_is_solved_without_measuring_again() {
let (frames, truth) = synthetic_sweep(6, 0.3, 1400.0, 1024, 768);
let opts = AlignOptions::default();
let pairs = match_pairs(&frames, &opts).expect("measured");
// The first frame left out: five cameras, indexed as the kept
// frames, and the links among them only.
let keep = [false, true, true, true, true, true];
let out = solve(&pairs, &keep, &opts).expect("solved");
assert!(out.is_complete(), "unaligned: {:?}", out.unaligned);
assert_eq!(out.rotations.len(), 5);
assert_eq!(out.links.len(), 4 + 3, "links: {}", out.links.len());
let root = out
.rotations
.iter()
.position(|r| *r == Some(Mat3::IDENTITY))
.unwrap();
for k in 0..5 {
let rel_truth = truth.rotations[root + 1].transpose() * truth.rotations[k + 1];
let err = angle_between(rel_truth, out.rotations[k].unwrap());
assert!(err < 2e-3, "frame {k} off by {err} rad");
}
// A frame in the middle left out splits the sweep only if nothing
// spans the gap; at 0.3 rad steps its neighbours still overlap.
let keep = [true, true, false, true, true, true];
let out = solve(&pairs, &keep, &opts).expect("solved");
assert!(out.is_complete(), "unaligned: {:?}", out.unaligned);
// And the whole set solved from the pairs is `align`'s answer.
assert_eq!(
solve(&pairs, &[true; 6], &opts).expect("solved"),
align(&frames, &opts).expect("aligned")
);
}
#[test] #[test]
fn one_frame_is_refused() { fn one_frame_is_refused() {
let (frames, _) = synthetic_sweep(1, 0.3, 1400.0, 640, 480); let (frames, _) = synthetic_sweep(1, 0.3, 1400.0, 640, 480);
+1 -1
View File
@@ -46,7 +46,7 @@ pub mod projection;
#[cfg(feature = "xfeat")] #[cfg(feature = "xfeat")]
pub mod xfeat; pub mod xfeat;
pub use align::{align, AlignOptions, Alignment, Link, Unaligned}; pub use align::{align, match_pairs, solve, AlignOptions, Alignment, Link, Pairs, Unaligned};
pub use bundle::Cameras; pub use bundle::Cameras;
pub use features::{Features, Keypoint}; pub use features::{Features, Keypoint};
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams}; pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
+10
View File
@@ -405,6 +405,7 @@ fn emit_node(out: &mut String, node: &Declaration) {
active, active,
tests, tests,
presentation, presentation,
camera_stage,
.. ..
} = node; } = node;
@@ -569,6 +570,15 @@ fn emit_node(out: &mut String, node: &Declaration) {
" fn is_active(&self) -> bool {{\n {active_expr}\n }}\n" " fn is_active(&self) -> bool {{\n {active_expr}\n }}\n"
); );
// Only a camera-stage node says anything: the trait's default is the
// scene, which is every other node (D19).
if *camera_stage {
out.push_str(
" fn stage(&self) -> crate::operation::Stage {\n \
crate::operation::Stage::Camera\n }\n\n",
);
}
let _ = writeln!( let _ = writeln!(
out, out,
" fn wgsl_body(&self) -> String {{\n {}.into()\n }}\n", " fn wgsl_body(&self) -> String {{\n {}.into()\n }}\n",
+45 -47
View File
@@ -228,9 +228,11 @@ interpolated points — master, red, green, blue — each reaching the shader on
when it has been moved), `colour_mixer` (thirty-six faceted parameters from when it has been moved), `colour_mixer` (thirty-six faceted parameters from
twelve computed hue bands), `film_sim` (a stock's measured tables, which are twelve computed hue bands), `film_sim` (a stock's measured tables, which are
not parameters, and the one node that declares `Operation::renders` — see not parameters, and the one node that declares `Operation::renders` — see
below), `capture_sharpen` (a separable convolution) and `noise_reduction` (a below), `view_transform` (composed at its defaults, which a declaration cannot
kernel, and one that decides how many dispatches to emit at each resolution) — say — see [What is not a node](#what-is-not-a-node-and-why)), and the five
the last two for the reason the next section gives. `vignetting` is kernels — `capture_sharpen` (a separable convolution), `noise_reduction` (one
that decides how many dispatches to emit at each resolution), `clarity`,
`texture` and `dehaze` — for the reason the next section gives. `vignetting` is
hand-written too but is not in the develop chain — it carries lens-profile hand-written too but is not in the develop chain — it carries lens-profile
coefficients that are not parameters. coefficients that are not parameters.
@@ -240,18 +242,16 @@ coefficients that are not parameters.
`Operation::renders`, and it is worth knowing why before writing a second one. `Operation::renders`, and it is worth knowing why before writing a second one.
Every other node *adjusts* a picture. That one *makes* it: a film stock's Every other node *adjusts* a picture. That one *makes* it: a film stock's
characteristic curve does the camera profile's base curve's job, from characteristic curve does the view transform's job, from measurements rather
measurements rather than from a curve somebody drew. Running both renders the than from a curve somebody chose. Running both renders the scene twice — the
scene twice — the camera's rendering, and then a film's rendering of *that* — default rendering, and then a film's rendering of *that* — which looks like
which looks like neither and reads as a colour-management bug with no neither and reads as a colour-management bug with no colour-management bug to
colour-management bug to find. find.
So a node declaring `renders` takes camera RGB and hands back linear sRGB, and So `film_sim` is in `Stage::View` beside `view_transform`, and while a stock
in exchange the composer emits neither the base curve nor the conversion out of is loaded the composer emits it in the view transform's place, last, after the
camera space. Both halves move to the node, together: the base curve is defined detail stage, and not the sigmoid (D19). It is handed working-space colour and
in camera RGB and the matrix is what leaves it, so a node replacing one has hands back display-referred linear sRGB for the output transform. `distortion` and
necessarily replaced the other. `compose_full` keeps them as a single string
for exactly that reason — it is what makes getting half of it right impossible. `distortion` and
`aberration` are `Warp`s rather than operations: they rewrite coordinates `aberration` are `Warp`s rather than operations: they rewrite coordinates
before sampling rather than transforming a colour after it. before sampling rather than transforming a colour after it.
@@ -264,7 +264,8 @@ clarity, texture, dehaze and spot removal are all defined by what the
of `c` at any price. of `c` at any price.
They go in the **detail stage**, which runs after the fused pass, in linear They go in the **detail stage**, which runs after the fused pass, in linear
light, at render resolution, before the output transform — see light, at render resolution, before the view transform and the output
transform — see
[`../src/detail.rs`](../src/detail.rs) for why each of those is a decision [`../src/detail.rs`](../src/detail.rs) for why each of those is a decision
rather than a convenience. A node of this kind: rather than a convenience. A node of this kind:
@@ -294,41 +295,38 @@ in raw pixels is a different photograph on screen and in the exported file.
## What is not a node, and why ## What is not a node, and why
Three things act on every pixel and are deliberately not in this directory: Two things act on every pixel and are deliberately not in this directory: the
the as-shot white balance, the camera matrix, and the **base curve** as-shot white balance and the camera matrix. They are emitted by
(FR-DEV-3e). They are emitted by [`../src/operation.rs`](../src/operation.rs) [`../src/operation.rs`](../src/operation.rs) into the composed shader around the
into the composed shader's fixed preamble, around the block of nodes. block of nodes. They are properties of the *file*, at the same standing as the
masked-photosite crop (FR-RAW-3) and the stored orientation (FR-DEV-3h): nobody
chose the sensor's green sensitivity, and reading the file correctly means
undoing it.
The test is not "does it transform a colour" — all three do. It is **whose The **view transform** (FR-DEV-3j) *is* a node — `view_transform.yaml`, a
decision is it**. A node is something a photographer chose: it has parameters, `rust:` one — and that is a change of mind worth knowing about. It replaced the
it moves off a neutral, it lands in the sidecar, it can be undone. These three per-body base curve, which was kept out of this directory because it belonged
are properties of the *file*, at the same standing as the masked-photosite crop to the camera: as a node it would have carried one body's rendering onto
(FR-RAW-3) and the stored orientation (FR-DEV-3h). Nobody chose the sensor's another body's file through a shared sidecar. D19 retired the per-body curves,
green sensitivity or the body's rendering; they are what reading the file and with them the argument. One view transform serves every body, so its
correctly means. settings are a decision about the picture like any other. What is still
special about it is `Stage::View`: the composer emits it at the end of the
chain *whatever its state*, because a photograph with no view transform is a
scan and not a picture. Its neutral is its defaults, like every other node's,
so an untouched photograph writes nothing for it.
Making the base curve a node would have said the opposite in four places at ## Stages
once. It would have appeared in the develop panel as a control, so an
unprofiled body would show a slider that does nothing. Its values would have
gone into the sidecar, and sidecars are shared between devices and bodies
(FR-NC-9) — one camera's rendering would follow an edit onto another camera's
file. Its neutral would have had to be "the identity", so a profiled body would
open reporting itself modified. And there is no seam through which a node could
learn which camera took the frame: the profile arrives on the decoded image,
travels through `DemosaicedImage` beside the matrix it belongs with, and is
written into the uniform block by the same three lines in `dr-gpu` — which is
exactly the path the matrix already took, because it is exactly the same kind
of thing.
What it *does* share with the tone curve node is the spline. The composer asks `stage: camera` puts a node in camera RGB, ahead of the camera matrix; the
`ToneCurve` for its `curve_span`/`curve_eval` helpers rather than emitting a default, `stage: scene`, hands it working-space colour — linear sRGB
second copy, so a profile author placing a control point and a photographer primaries, scene-referred and unbounded. White balance is the only camera
dragging one mean the same thing by it. node, because its multipliers scale the sensor's own channels. Everything else
belongs in the scene, where a hue or a luminance weight means the same thing
The order still reads correctly from this directory: the base curve runs after whichever body took the frame (D19). The composer emits the camera nodes, then
every node in the chain and before the conversion out of camera space. That is the matrix, then the scene nodes, each group in `order:`, and the view
the same reasoning `exposure` records under `placement:` — corrections to transform last. `stage: view` is not offered to a declaration: a node that
capture are only meaningful on linear values, so the rendering goes last. maps into a display range is exactly what ARCH §6.14 forbids of everything
before the end, and the one that is allowed to is hand-written.
## Errors ## Errors
+63 -28
View File
@@ -35,41 +35,63 @@ helpers: [luminance, apply_tone_gain]
define: define:
contrast_curve: | contrast_curve: |
// A symmetric S-curve on a 0..1 perceptual position. // The steepening S, on a 0..1 perceptual position.
// //
// `amount` above zero steepens, below zero flattens. The smoothstep form is // Blends toward a smoothstep, which has zero gradient at both ends, so the
// used for the steepening direction because it has zero gradient at both // curve cannot invert however hard it is pushed — the failure that makes
// ends, so the curve cannot invert however hard it is pushed — the failure // naive gain-about-a-pivot unusable past moderate settings. Only the
// that makes naive gain-about-a-pivot unusable past moderate settings. // positive direction comes here: flattening is not a curve at all (see
// the fragment).
fn contrast_curve(x: f32, amount: f32) -> f32 { fn contrast_curve(x: f32, amount: f32) -> f32 {
let clamped = clamp(x, 0.0, 1.0); let clamped = clamp(x, 0.0, 1.0);
if (amount >= 0.0) { let s = clamped * clamped * (3.0 - 2.0 * clamped);
// Blend toward a smoothstep, which is the S. return mix(clamped, s, amount);
let s = clamped * clamped * (3.0 - 2.0 * clamped);
return mix(clamped, s, amount);
}
// Flattening: pull toward the mid-point. At amount = -1 every tone
// collapses to 0.5, which is the meaningful limit of 'no contrast'.
return mix(clamped, 0.5, -amount);
} }
wgsl: | wgsl: |
let luma = luminance(c); if (amount < 0.0) {
if (luma > 0.0001) { // **Flattening mixes toward middle grey; it does not scale.**
// Work on luminance and rescale the colour by the ratio, rather than
// curving each channel independently. Per-channel contrast shifts hue
// wherever the channels differ — the classic symptom being skies going
// cyan as contrast rises.
// //
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone. The // Every tone moves the same fraction of the way to 0.18, which at -1
// curve operates on luma/(2*0.18) so that middle grey lands at the // collapses the picture to grey — the meaningful limit of 'no contrast'.
// curve's own 0.5 pivot. // In luminance this is exactly what the ratio form below would compute,
let pos = clamp(luma / 0.36, 0.0, 1.0); // but the ratio form reaches it by multiplying: a pixel at 0.001 has to
let curved = contrast_curve(pos, amount); // be lifted to 0.09, a gain of ninety, and in the deepest shadows the
// Not `target`: that is a WGSL reserved keyword, and using it produces a // channels are sensor noise, not a colour. After white balance the red
// parse error in generated code rather than anywhere a reader would look. // and blue noise sits above the green (their multipliers are nearly
let curved_luma = curved * 0.36; // twice its), so ninety times that noise is magenta — every black in the
c = apply_tone_gain(c, curved_luma / luma); // frame turned pink. Mixing adds the lift as a neutral, so a black goes
// to grey and its noise stays the size it was.
//
// The grey is (1, 1, 1) scaled, because this runs after white balance
// and the camera matrix, which carries a balanced neutral to equal
// channels.
c = mix(c, vec3<f32>(0.18), -amount);
} else {
let luma = luminance(c);
// Only up to twice middle grey, which is the curve's whole domain. Above
// it the curve's value is 1 and its slope 0, so leaving those tones
// alone is the continuous continuation — where scaling them to the
// curve's top, as this once did through a clamp, pinned every highlight
// in the photograph to 0.36 at the smallest touch of the slider.
if (luma > 0.0001 && luma < 0.36) {
// Work on luminance and rescale the colour by the ratio, rather than
// curving each channel independently. Per-channel contrast shifts hue
// wherever the channels differ — the classic symptom being skies going
// cyan as contrast rises. Safe here where it was not for flattening:
// the S only ever pulls a shadow down, so the gain is at most one
// below the pivot and noise is never amplified.
//
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone.
// The curve operates on luma/(2*0.18) so that middle grey lands at
// the curve's own 0.5 pivot.
let pos = luma / 0.36;
let curved = contrast_curve(pos, amount);
// Not `target`: that is a WGSL reserved keyword, and using it produces a
// parse error in generated code rather than anywhere a reader would look.
let curved_luma = curved * 0.36;
c = apply_tone_gain(c, curved_luma / luma);
}
} }
c = max(c, vec3<f32>(0.0)); c = max(c, vec3<f32>(0.0));
@@ -104,6 +126,19 @@ tests:
propagate through everything downstream. propagate through everything downstream.
expect_wgsl: ["luma > 0.0001"] expect_wgsl: ["luma > 0.0001"]
- name: flattening_mixes_toward_grey_rather_than_scaling
why: |
Lifting a shadow by a luminance ratio multiplies its noise by the same
ratio — ninety at the bottom of a night photograph — and after white
balance that noise is magenta. A mix adds the lift as a neutral.
expect_wgsl: ["mix(c, vec3<f32>(0.18), -amount)"]
- name: highlights_are_not_pinned_to_the_top_of_the_curve
why: |
The curve covers 0..0.36. A clamp into that range scaled every brighter
pixel down to 0.36; tones above it are left as they are.
expect_wgsl: ["luma < 0.36"]
- name: the_curve_cannot_invert - name: the_curve_cannot_invert
why: | why: |
A gain-about-a-pivot form produces a non-monotonic curve past moderate A gain-about-a-pivot form produces a non-monotonic curve past moderate
+12 -10
View File
@@ -1,5 +1,5 @@
id: film_sim id: film_sim
order: 25 order: 190
# What this node is *about* is not written here, and cannot be: a `rust:` node # What this node is *about* is not written here, and cannot be: a `rust:` node
# publishes its own descriptor, so `attributes:` in this file would be read, # publishes its own descriptor, so `attributes:` in this file would be read,
# validated and then ignored. See `Attribute::Effect` on `FilmSim`'s descriptor # validated and then ignored. See `Attribute::Effect` on `FilmSim`'s descriptor
@@ -11,15 +11,17 @@ why_rust: |
characteristic curves and a density lookup — which are not parameters and characteristic curves and a density lookup — which are not parameters and
which no `uniforms:` expression could produce. Its neutral is "no stock which no `uniforms:` expression could produce. Its neutral is "no stock
loaded" rather than a set of values, and it is the one node that declares loaded" rather than a set of values, and it is the one node that declares
`Operation::renders`, so the composer omits the camera profile's base curve `Operation::renders`, so while a stock is loaded the composer emits it in
and the conversion out of camera space on its behalf. the view transform's place instead of the default sigmoid.
placement: | placement: |
After white balance and exposure, and before everything else. Last, in the view transform's place (D19, FR-DEV-3j), after every other
operation and after the detail stage.
Those two are what the camera did — interpreting the sensor, and correcting Before D19 it sat at order 25, after white balance and exposure, and every
the amount of light that reached it — and they are only meaningful on decision below it acted on the film's output, as though the frame had been
scene-linear values, which is what a film has to be handed. Everything below scanned and then worked on. That put a display-referred rendering in the
is a decision about the picture, and a decision about the picture belongs middle of the chain, which is what D19 removes: every operation is now handed
after the film has rendered it, exactly as it does when you scan a frame and the scene, and the film is the last thing that happens to the picture — an
then work on the scan. edit is a decision about the exposure the negative receives. `Stage::View`
is what puts it there; this number only places it in the panel's order.
+18
View File
@@ -0,0 +1,18 @@
id: view_transform
order: 200
# A `rust:` node publishes its own descriptor; its attributes are on the type
# in `../src/ops/view_transform.rs`.
rust: ViewTransform
why_rust: |
It is composed at its defaults — a photograph with no view transform is a
scan, not a picture — which is `Stage::View`, and a declaration has no way to
say it. Its three uniforms are also the solution of two equations rather than
expressions over its parameters (`dr_pipeline::view::Sigmoid::new`).
placement: |
Last, after every scene operation and, when there is one, after the detail
stage (D19, FR-DEV-3j). It is the one stage allowed to map scene-linear colour
to a display range, so anything after it would be working on a rendering.
The order here only places it in the panel; the composer puts every
`Stage::View` node at the end whatever its number says.
+7
View File
@@ -20,6 +20,13 @@ placement: |
First. It is a correction to how the scene was captured, and every tonal First. It is a correction to how the scene was captured, and every tonal
operation after it should act on a correctly balanced image. operation after it should act on a correctly balanced image.
# In camera RGB, ahead of the camera matrix, and the only node there (D19).
# Its multipliers scale the sensor's own channels — that is what the as-shot
# ones are, and what the picker solves for — and a matrix that mixes the
# channels, which is every body's, would turn the same numbers into a
# different correction once it had run.
stage: camera
params: params:
temperature: temperature:
label: param.temperature label: param.temperature
+37
View File
@@ -0,0 +1,37 @@
drpl 1
# Black and white film: each preset is one measured stock, developed and — for a
# negative — printed on the paper its profile names, with every other
# control left where it was. The stock is the look; a grade on top is the
# photographer's to add.
#
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
# converted in core/dr-film/profiles. A preset names a stock by id, so
# these lines are that attribution's reach: nothing of the data is here.
[preset Ilford Delta 100]
film = ilford_delta_100
film_print = kodak_2302
[preset Ilford Delta 400]
film = ilford_delta_400
film_print = kodak_2302
[preset Ilford FP4 Plus]
film = ilford_fp4_plus
film_print = kodak_2302
[preset Ilford HP5 Plus]
film = ilford_hp5_plus
film_print = kodak_2302
[preset Ilford Pan F Plus]
film = ilford_pan_f_plus
film_print = kodak_2302
[preset Kodak Double-X 5222]
film = kodak_doublex
film_print = kodak_2302
[preset Kodak Tri-X Reversal 7266]
film = kodak_trix
+30
View File
@@ -0,0 +1,30 @@
drpl 1
# Cinema film: each preset is one measured stock, developed and — for a
# negative — printed on the paper its profile names, with every other
# control left where it was. The stock is the look; a grade on top is the
# photographer's to add.
#
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
# converted in core/dr-film/profiles. A preset names a stock by id, so
# these lines are that attribution's reach: nothing of the data is here.
[preset Kodak Verita 200D]
film = kodak_verita_200d
film_print = kodak_2383
[preset Kodak Vision3 200T]
film = kodak_vision3_200t
film_print = kodak_2383
[preset Kodak Vision3 250D]
film = kodak_vision3_250d
film_print = kodak_2383
[preset Kodak Vision3 500T]
film = kodak_vision3_500t
film_print = kodak_2383
[preset Kodak Vision3 50D]
film = kodak_vision3_50d
film_print = kodak_2383
+66
View File
@@ -0,0 +1,66 @@
drpl 1
# Colour film: each preset is one measured stock, developed and — for a
# negative — printed on the paper its profile names, with every other
# control left where it was. The stock is the look; a grade on top is the
# photographer's to add.
#
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
# converted in core/dr-film/profiles. A preset names a stock by id, so
# these lines are that attribution's reach: nothing of the data is here.
[preset Fujifilm C200]
film = fujifilm_c200
film_print = fujifilm_crystal_archive_typeii
[preset Fujifilm Pro 400H]
film = fujifilm_pro_400h
film_print = fujifilm_crystal_archive_typeii
[preset Fujifilm Provia 100F]
film = fujifilm_provia_100f
[preset Fujifilm Velvia 100]
film = fujifilm_velvia_100
[preset Fujifilm X-Tra 400]
film = fujifilm_xtra_400
film_print = fujifilm_crystal_archive_typeii
[preset Kodak Ektachrome 100]
film = kodak_ektachrome_100
[preset Kodak Ektar 100]
film = kodak_ektar_100
film_print = kodak_portra_endura
[preset Kodak Gold 200]
film = kodak_gold_200
film_print = kodak_portra_endura
[preset Kodak Kodachrome 64]
film = kodak_kodachrome_64
[preset Kodak Portra 160]
film = kodak_portra_160
film_print = kodak_portra_endura
[preset Kodak Portra 400]
film = kodak_portra_400
film_print = kodak_portra_endura
[preset Kodak Portra 800]
film = kodak_portra_800
film_print = kodak_portra_endura
[preset Kodak Portra 800 pushed one stop]
film = kodak_portra_800_push1
film_print = kodak_portra_endura
[preset Kodak Portra 800 pushed two stops]
film = kodak_portra_800_push2
film_print = kodak_portra_endura
[preset Kodak Ultramax 400]
film = kodak_ultramax_400
film_print = kodak_portra_endura
+41
View File
@@ -0,0 +1,41 @@
drpl 1
# Essentials: small, general corrections written against this pipeline.
# These were the first-run starter set; they ship here now so that a
# new release can improve them without rewriting anybody's own presets.
# Deliberately mild — see bundled.rs.
[preset Crisp detail]
capture_sharpen.amount = 35
clarity.amount = 10
texture.amount = 20
[preset Lift the shadows]
blacks_whites.blacks = 12
contrast.contrast = -5
highlights_shadows.shadows = 40
[preset Muted]
contrast.contrast = -10
highlights_shadows.shadows = 12
saturation.saturation = -30
vibrance.vibrance = 10
[preset Punch]
blacks_whites.blacks = -8
clarity.amount = 12
contrast.contrast = 18
vibrance.vibrance = 18
[preset Recover the sky]
blacks_whites.whites = -10
highlights_shadows.highlights = -55
highlights_shadows.shadows = 35
[preset Soft portrait]
clarity.amount = -10
contrast.contrast = -8
highlights_shadows.highlights = -20
highlights_shadows.shadows = 15
saturation.saturation = -5
vibrance.vibrance = 10
+52
View File
@@ -0,0 +1,52 @@
drpl 1
# Skies: a bluer, deeper sky without touching the rest of the picture.
#
# Written against this pipeline, not derived from anybody's preset. Each
# works the colour mixer's azure and blue bands — the hues a clear sky
# occupies, 210° and 240° — darkening them and adding chroma, which is what
# a polarising filter does to a sky and why it reads as "more blue" rather
# than "more saturated". A grey sky has no hue for the bands to find, so on
# an overcast frame these do little, by construction: they cannot invent a
# sky, and a preset that tinted grey clouds blue would be one nobody trusted.
#
# Highlights come down with the sky in the stronger ones, because a darker
# blue beside a clipped white cloud looks like a mask edge.
[preset Blue sky]
colour_mixer.azure_lum = -20
colour_mixer.azure_sat = 25
colour_mixer.blue_lum = -15
colour_mixer.blue_sat = 20
highlights_shadows.highlights = -15
[preset Deep blue sky]
colour_mixer.azure_hue = 10
colour_mixer.azure_lum = -30
colour_mixer.azure_sat = 35
colour_mixer.blue_lum = -25
colour_mixer.blue_sat = 30
colour_mixer.cyan_sat = 10
highlights_shadows.highlights = -30
[preset Polariser]
colour_mixer.azure_hue = 10
colour_mixer.azure_lum = -35
colour_mixer.azure_sat = 40
colour_mixer.blue_lum = -30
colour_mixer.blue_sat = 35
colour_mixer.cyan_lum = -10
colour_mixer.cyan_sat = 15
dehaze.amount = 20
highlights_shadows.highlights = -35
vibrance.vibrance = 10
[preset Blue sky, golden land]
colour_mixer.azure_lum = -20
colour_mixer.azure_sat = 25
colour_mixer.blue_lum = -15
colour_mixer.blue_sat = 20
colour_mixer.orange_sat = 12
colour_mixer.yellow_hue = -10
colour_mixer.yellow_sat = 15
highlights_shadows.highlights = -20
+447
View File
@@ -0,0 +1,447 @@
//! TRACES: FR-DEV-6
//! The presets that ship with the application.
//!
//! # Shipped, not seeded
//!
//! The first six used to be *copied* into the photographer's own library on
//! the first run and were theirs from then on. That was the right answer for
//! six, and it cannot grow: a copy is frozen at the version that made it, so a
//! better "Portra" in the next release would reach nobody who already had the
//! old one, and re-seeding would overwrite a preset someone had tuned. So the
//! shipped set is now read from the binary every time, never written to the
//! user's file, and changes when the application does.
//!
//! # Your copy wins, as long as it keeps the name
//!
//! Saving over a shipped preset's name makes the photographer's version the
//! one that name means, here and in every apply. It is still *that* preset —
//! listed where the shipped one was, marked as changed — and deleting it
//! reveals the shipped one again, which is what "revert" means to the person
//! pressing it. Renaming it cuts the link: it becomes one of their own, and
//! the shipped preset reappears beside it. The lookup is by name because the
//! name is what the photographer sees and chooses by; an id they never see
//! would link two presets they believe are different.
//!
//! # Looks, not whole edits
//!
//! Every shipped preset reaches only the operations it names
//! ([`Reach::Named`]): a look applied to a corrected photograph must keep the
//! correction. A photographer's own saved edits keep [`Reach::Whole`], which
//! is what saving an edit has always meant.
//!
//! # Why the data is text files
//!
//! The same format the user's library is written in, so a shipped preset can
//! be read, diffed and copied into one's own library by hand, and each file
//! can say in a comment where its looks came from — the attribution a licence
//! may require travels with the data it covers.
//!
//! # Why this lives in the core
//!
//! It names operations — "Punch" is a statement about contrast and clarity —
//! and nothing in `ui/` may (`ui_names_no_operation.rs`, ARCH §4.3a). The
//! frontend asks for the listing and applies what it is handed.
use crate::preset::{Preset, PresetLibrary, Reach};
/// One group of shipped presets, as the sheet lists it.
pub struct Section {
/// A stable identifier, for a frontend that remembers which sections a
/// photographer folded away. Never shown.
pub id: &'static str,
/// What the section is called on screen, as a category path: `/`
/// separates the levels, so `Film/Colour` is a folder inside `Film`. The
/// same spelling a photographer's own preset names use for theirs.
pub title: &'static str,
/// The presets in it, every one reaching only what it names.
pub presets: PresetLibrary,
}
/// The files, in the order the sheet lists them.
const SECTIONS: &[(&str, &str, &str)] = &[
(
"essentials",
"Essentials",
include_str!("../presets/essentials.drpl"),
),
("skies", "Skies", include_str!("../presets/skies.drpl")),
(
"colour_film",
"Film/Colour",
include_str!("../presets/colour_film.drpl"),
),
(
"cinema_film",
"Film/Cinema",
include_str!("../presets/cinema_film.drpl"),
),
(
"bw_film",
"Film/Black and white",
include_str!("../presets/bw_film.drpl"),
),
];
/// Every shipped section, parsed.
///
/// Parsed on each call rather than held: it is a few kilobytes read when the
/// preset sheet is drawn, and a static would be one more thing to keep
/// consistent with the files in a test. A file that fails to parse costs its
/// section, with a warning, rather than the sheet — and the tests below make
/// sure none does.
pub fn sections() -> Vec<Section> {
SECTIONS
.iter()
.filter_map(|(id, title, text)| match PresetLibrary::parse(text) {
Ok(library) => Some(Section {
id,
title,
presets: as_looks(library),
}),
Err(e) => {
log::warn!("shipped preset section {id} is unreadable ({e}); skipping");
None
}
})
.collect()
}
/// Mark every preset in `library` as a look.
///
/// Here rather than as a `reach = named` line in every block of every file:
/// the rule is about where a preset came from, and a file that forgot the
/// line would ship a preset that wiped a photographer's corrections.
fn as_looks(library: PresetLibrary) -> PresetLibrary {
let mut looks = PresetLibrary::default();
for (name, preset) in library.iter() {
let _ = looks.insert(name, preset.clone().with_reach(Reach::Named));
}
looks
}
/// Where a listed preset comes from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Origin {
/// The photographer's own, with no shipped preset of that name.
Yours,
/// Shipped, and not overridden.
Shipped,
/// Shipped, and overridden by the photographer's copy under the same
/// name. The copy is what applies; deleting it reverts to the shipped one.
Changed,
}
/// One row of the preset sheet.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listed {
pub name: String,
pub origin: Origin,
}
/// One group of rows: the photographer's own, then each shipped section.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListedSection {
/// `None` for the photographer's own presets.
pub id: Option<&'static str>,
pub title: &'static str,
pub rows: Vec<Listed>,
}
/// Everything the sheet lists, in order, with the photographer's copies
/// standing in for the shipped presets they override.
///
/// Their own presets come first, because a photographer reaches for their own
/// work more than for anybody's defaults, and a copy of a shipped preset is
/// listed in the shipped section rather than among their own — it is still
/// that preset, changed, and belongs where they would look for it.
pub fn listing(yours: &PresetLibrary) -> Vec<ListedSection> {
let shipped = sections();
let is_shipped = |name: &str| shipped.iter().any(|section| section.presets.contains(name));
let mut out = vec![ListedSection {
id: None,
title: "Yours",
rows: yours
.names()
.filter(|name| !is_shipped(name))
.map(|name| Listed {
name: name.to_string(),
origin: Origin::Yours,
})
.collect(),
}];
out.extend(shipped.iter().map(|section| {
ListedSection {
id: Some(section.id),
title: section.title,
rows: section
.presets
.names()
.map(|name| Listed {
name: name.to_string(),
origin: if yours.contains(name) {
Origin::Changed
} else {
Origin::Shipped
},
})
.collect(),
}
}));
out
}
/// The preset a name means: the photographer's if they have one, the shipped
/// one otherwise.
pub fn lookup(yours: &PresetLibrary, name: &str) -> Option<Preset> {
yours.get(name).cloned().or_else(|| {
sections()
.into_iter()
.find_map(|section| section.presets.get(name).cloned())
})
}
/// Whether `name` is a shipped preset's.
pub fn is_shipped(name: &str) -> bool {
sections()
.iter()
.any(|section| section.presets.contains(name))
}
/// Remove the copies a first run used to seed, where they are still exactly
/// as seeded. Returns how many went.
///
/// Those copies would otherwise all list as changed — overriding a shipped
/// preset with an identical one — and would freeze the six at their old
/// values forever. One that differs in any way was tuned by somebody and is
/// kept: it is theirs, and it now overrides the shipped one, which is the
/// rule above doing what it is for.
///
/// Compared on the parameters and the film and not on [`Reach`]: the seeded
/// copies were whole edits, the shipped ones are looks, and that difference
/// is the thing this migration exists to deliver.
pub fn forget_unchanged_copies(yours: &mut PresetLibrary) -> usize {
let shipped = sections();
let stale: Vec<String> = yours
.iter()
.filter(|(name, preset)| {
shipped.iter().any(|section| {
section
.presets
.get(name)
.is_some_and(|s| s.params() == preset.params() && s.film() == preset.film())
})
})
.map(|(name, _)| name.to_string())
.collect();
for name in &stale {
yours.remove(name);
}
stale.len()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EditGraph, Scope};
fn all() -> Vec<(&'static str, String, Preset)> {
sections()
.into_iter()
.flat_map(|s| {
let id = s.id;
s.presets
.iter()
.map(|(n, p)| (id, n.to_string(), p.clone()))
.collect::<Vec<_>>()
})
.collect()
}
#[test]
fn every_file_parses_and_every_line_is_understood() {
// A misspelt key would otherwise be kept as a line this build does
// not understand — preserved faithfully, and doing nothing.
assert_eq!(
sections().len(),
SECTIONS.len(),
"a section failed to parse"
);
for (id, _, text) in SECTIONS {
let library = PresetLibrary::parse(text).unwrap();
assert_eq!(library.unread_lines(), 0, "{id} has lines nobody reads");
assert!(!library.is_empty(), "{id} is empty");
}
}
#[test]
fn every_shipped_name_is_unique_across_sections() {
// A name is what the lookup and the override key on. Two shipped
// presets sharing one would make one of them unreachable.
let mut names: Vec<String> = all().into_iter().map(|(_, n, _)| n).collect();
let before = names.len();
names.sort();
names.dedup();
assert_eq!(names.len(), before);
}
#[test]
fn every_shipped_preset_is_a_look() {
for (id, name, preset) in all() {
assert_eq!(preset.reach(), Reach::Named, "{id}/{name}");
}
}
#[test]
fn every_shipped_preset_names_parameters_this_build_actually_has() {
// A renamed parameter must break the build rather than ship a preset
// that quietly does nothing.
let graph = EditGraph::default_chain();
let capabilities = graph.capabilities();
for (id, name, preset) in all() {
for (op, param) in preset.params().keys() {
let capability = capabilities
.iter()
.find(|c| c.id.0 == op)
.unwrap_or_else(|| panic!("{id}/{name}: no operation {op:?}"));
assert!(
capability.params.iter().any(|p| p.id.0 == param),
"{id}/{name}: operation {op:?} has no parameter {param:?}"
);
}
}
}
#[test]
fn every_shipped_preset_changes_something() {
// A preset that applies to nothing teaches the photographer that the
// list does not work.
for (id, name, preset) in all() {
let mut graph = EditGraph::default_chain();
let rebake = preset.apply(&mut graph, Scope::adjustments());
assert!(
rebake.wanted().is_some() || Preset::capture(&graph) != Preset::default(),
"{id}/{name} left the graph at its defaults"
);
}
}
#[test]
fn no_shipped_preset_carries_a_crop() {
for (id, name, preset) in all() {
assert!(!preset.touches_framing(), "{id}/{name} carries framing");
}
}
#[test]
fn the_values_stay_inside_what_the_controls_accept() {
// Clamping happens on apply, so an out-of-range literal would be
// silently trimmed and the preset would not be the one written.
for (id, name, preset) in all() {
let mut graph = EditGraph::default_chain();
preset
.clone()
.with_film(None)
.apply(&mut graph, Scope::everything())
.expect_no_film();
for ((op, param), value) in preset.params() {
let (op_id, param_id) = crate::preset::resolve(&graph, op, param).unwrap();
assert_eq!(
graph.param(op_id, param_id),
Some(*value),
"{id}/{name}: {op}.{param} = {value} was clamped"
);
}
}
}
// --- the listing and the override ------------------------------------
fn yours_with(names: &[(&str, Preset)]) -> PresetLibrary {
let mut lib = PresetLibrary::default();
for (n, p) in names {
lib.insert(n, p.clone()).unwrap();
}
lib
}
fn first_shipped() -> (String, Preset) {
let (_, name, preset) = all().into_iter().next().unwrap();
(name, preset)
}
#[test]
fn your_copy_under_a_shipped_name_is_what_that_name_applies() {
let (name, shipped) = first_shipped();
let mine = Preset::default();
let yours = yours_with(&[(&name, mine.clone())]);
assert_eq!(lookup(&yours, &name), Some(mine));
assert_eq!(lookup(&PresetLibrary::default(), &name), Some(shipped));
}
#[test]
fn your_copy_is_listed_in_the_shipped_section_as_changed() {
let (name, _) = first_shipped();
let yours = yours_with(&[(&name, Preset::default()), ("Mine", Preset::default())]);
let listing = listing(&yours);
assert_eq!(listing[0].id, None);
assert_eq!(
listing[0].rows,
vec![Listed {
name: "Mine".into(),
origin: Origin::Yours
}],
"an override must not be listed twice"
);
let row = listing[1..]
.iter()
.flat_map(|s| &s.rows)
.find(|r| r.name == name)
.unwrap();
assert_eq!(row.origin, Origin::Changed);
}
#[test]
fn deleting_your_copy_reverts_to_the_shipped_one() {
let (name, shipped) = first_shipped();
let mut yours = yours_with(&[(&name, Preset::default())]);
yours.remove(&name);
assert_eq!(lookup(&yours, &name), Some(shipped));
}
#[test]
fn renaming_your_copy_cuts_the_link() {
let (name, shipped) = first_shipped();
let mut yours = yours_with(&[(&name, Preset::default())]);
yours.rename(&name, "My version").unwrap();
assert_eq!(lookup(&yours, &name), Some(shipped));
assert_eq!(lookup(&yours, "My version"), Some(Preset::default()));
assert_eq!(listing(&yours)[0].rows[0].name, "My version");
}
#[test]
fn the_old_seeded_copies_are_forgotten_and_tuned_ones_kept() {
// The six as a first run wrote them: the same parameters, as whole
// edits, because that is what a seeded copy was.
let essentials = sections().into_iter().next().unwrap().presets;
let mut yours = PresetLibrary::default();
for (name, preset) in essentials.iter() {
yours
.insert(name, preset.clone().with_reach(Reach::Whole))
.unwrap();
}
let tuned = essentials.names().next().unwrap().to_string();
yours.insert(&tuned, Preset::default()).unwrap();
yours.insert("Mine", Preset::default()).unwrap();
let forgotten = forget_unchanged_copies(&mut yours);
assert_eq!(forgotten, essentials.len() - 1);
assert!(yours.contains(&tuned), "a tuned copy was thrown away");
assert!(yours.contains("Mine"));
assert_eq!(yours.len(), 2);
}
}
+29
View File
@@ -271,6 +271,32 @@ pub struct Declaration {
/// Boxed so the rare node that declares one does not widen every /// Boxed so the rare node that declares one does not widen every
/// declaration by the size of a presentation it does not have. /// declaration by the size of a presentation it does not have.
pub presentation: Option<Box<PresentationDef>>, pub presentation: Option<Box<PresentationDef>>,
/// Whether the node runs in camera RGB, ahead of the camera matrix —
/// `stage: camera`. See [`read_stage`].
pub camera_stage: bool,
}
/// TRACES: FR-DEV-3e | FR-DEV-2
/// Where in the chain a declared node's colour comes from: `stage: camera` or
/// `stage: scene`, the default.
///
/// Camera RGB is where white balance's multipliers are defined, and it is the
/// only thing that belongs there (D19): every other operation is handed
/// working-space colour, so that a hue or a luminance weight means the same
/// thing whichever body took the frame. `view` is not offered. The view
/// transform is hand-written, and a declared node that clipped into a display
/// range would be exactly what ARCH §6.14 forbids of every node before it.
fn read_stage(root: &Mapping) -> Result<bool, String> {
match root.get("stage") {
None => Ok(false),
Some(v) => match as_str(v, "stage")? {
"camera" => Ok(true),
"scene" => Ok(false),
other => Err(format!(
"unknown stage {other:?}; expected \"camera\" or \"scene\""
)),
},
}
} }
impl Declaration { impl Declaration {
@@ -512,6 +538,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
"define", "define",
"label", "label",
"attributes", "attributes",
"stage",
] { ] {
if root.contains_key(key) { if root.contains_key(key) {
return Err(format!( return Err(format!(
@@ -564,6 +591,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
.collect(); .collect();
let tests = read_tests(root, &params, &uniform_names, &helper_names)?; let tests = read_tests(root, &params, &uniform_names, &helper_names)?;
let presentation = read_presentation(root, &param_names)?; let presentation = read_presentation(root, &param_names)?;
let camera_stage = read_stage(root)?;
Ok(Node::Declared(Box::new(Declaration { Ok(Node::Declared(Box::new(Declaration {
id, id,
@@ -580,6 +608,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
active, active,
tests, tests,
presentation, presentation,
camera_stage,
}))) })))
} }
+11
View File
@@ -107,6 +107,8 @@ pub struct DeclaredOp {
helpers: Vec<Helper>, helpers: Vec<Helper>,
presentation: Option<Presentation>, presentation: Option<Presentation>,
order: i64, order: i64,
/// See `decl::read_stage`.
camera_stage: bool,
} }
/// One uniform: the name the fragment reads it by, and how to compute it. /// One uniform: the name the fragment reads it by, and how to compute it.
@@ -208,6 +210,7 @@ impl DeclaredOp {
wgsl: declaration.wgsl_body(), wgsl: declaration.wgsl_body(),
helpers, helpers,
presentation: declaration.presentation.as_deref().map(presentation), presentation: declaration.presentation.as_deref().map(presentation),
camera_stage: declaration.camera_stage,
order: declaration.order, order: declaration.order,
}) })
} }
@@ -292,6 +295,14 @@ impl Operation for DeclaredOp {
fn presentation(&self) -> Option<Presentation> { fn presentation(&self) -> Option<Presentation> {
self.presentation.clone() self.presentation.clone()
} }
fn stage(&self) -> crate::operation::Stage {
if self.camera_stage {
crate::operation::Stage::Camera
} else {
crate::operation::Stage::Scene
}
}
} }
/// A declared parameter as the descriptor the panel reads. /// A declared parameter as the descriptor the panel reads.
+4 -4
View File
@@ -659,10 +659,10 @@ impl Attribute {
/// ///
/// `Effect` after `Colour` is a look laid over a settled picture — and is /// `Effect` after `Colour` is a look laid over a settled picture — and is
/// the one arguable slot. A spectral film simulation declares /// the one arguable slot. A spectral film simulation declares
/// [`crate::Operation::renders`] and replaces the base curve, which is an /// [`crate::Operation::renders`] and takes the view transform's place at
/// argument for treating it as foundational rather than final; an array of /// the very end of the chain (D19), which is an argument for treating it as
/// six cannot say "last, except when it is first". The tension is recorded /// the rendering rather than one effect among others; an array of six
/// here rather than settled. /// cannot say that. The tension is recorded here rather than settled.
/// ///
/// Both ends were wrong for as long as this list only fed a row of chips /// Both ends were wrong for as long as this list only fed a row of chips
/// nobody reads in order. It stopped being harmless when the same list /// nobody reads in order. It stopped being harmless when the same list
+192 -252
View File
@@ -24,9 +24,10 @@
//! v //! v
//! +------------------------------------------+ //! +------------------------------------------+
//! | the fused point-operation pass | one dispatch //! | the fused point-operation pass | one dispatch
//! | white balance, exposure, tone, colour | //! | white balance (camera RGB) |
//! | the mask layers |
//! | camera RGB -> linear sRGB | //! | camera RGB -> linear sRGB |
//! | exposure, tone, colour |
//! | the mask layers |
//! +------------------------------------------+ //! +------------------------------------------+
//! | rgba16float, linear, **unclipped**, at render resolution //! | rgba16float, linear, **unclipped**, at render resolution
//! v //! v
@@ -34,7 +35,13 @@
//! | the detail stage - this module | one dispatch per pass //! | the detail stage - this module | one dispatch per pass
//! | sharpen, NR, clarity, texture, spots | //! | sharpen, NR, clarity, texture, spots |
//! +------------------------------------------+ //! +------------------------------------------+
//! | the last pass applies the output transform //! | rgba16float, still scene-linear and unclipped
//! v
//! +------------------------------------------+
//! | the view pass | one dispatch
//! | view transform, or the film stock |
//! | output transform, mask reveal |
//! +------------------------------------------+
//! v //! v
//! rgba8unorm display or export texture //! rgba8unorm display or export texture
//! ``` //! ```
@@ -52,14 +59,13 @@
//! texture, clarity, spot removal and sharpen/NR sit below the tone curve and //! texture, clarity, spot removal and sharpen/NR sit below the tone curve and
//! the colour mixer. //! the colour mixer.
//! //!
//! **In linear light, after the camera matrix.** The fused pass works in //! **In linear light, after the camera matrix.** A detail pass wants a
//! *camera* space, because white balance and exposure are physically //! luminance, and camera RGB has no luminance — the three channels are
//! meaningful there and nowhere else. A detail pass is the opposite case: it
//! wants a luminance, and camera RGB has no luminance — the three channels are
//! whatever the CFA's dyes passed, and weighting them 0.2126/0.7152/0.0722 //! whatever the CFA's dyes passed, and weighting them 0.2126/0.7152/0.0722
//! would be numerology. So the split is taken *after* the `cam_to_srgb` //! would be numerology. Since D19 only white balance runs in camera RGB; the
//! multiply, where the working space is linear sRGB and a luminance is a //! `cam_to_srgb` multiply follows it, so every point operation, and every
//! luminance. //! detail pass after them, works in linear sRGB primaries, where a luminance
//! is a luminance.
//! //!
//! **Before the output transform, and before the clip.** FR-DEV-2 allows //! **Before the output transform, and before the clip.** FR-DEV-2 allows
//! exactly one quantisation, at the display or export stage. A detail pass //! exactly one quantisation, at the display or export stage. A detail pass
@@ -70,9 +76,11 @@
//! therefore `rgba16float` and holds linear values that have **not** been //! therefore `rgba16float` and holds linear values that have **not** been
//! clamped to `0..=1`: a recovered highlight is still above one at this point, //! clamped to `0..=1`: a recovered highlight is still above one at this point,
//! and clipping it before the sharpener sees it would put a hard edge exactly //! and clipping it before the sharpener sees it would put a hard edge exactly
//! where the sharpener is most visible. The last detail pass performs the //! where the sharpener is most visible. Every detail pass writes such an
//! primaries conversion, the clip and the encode, so the single quantisation //! intermediate, the last one included, and the view pass after them — the
//! stays single. //! view transform, then the output transform's primaries, clip and encode —
//! is the one place the scene is fitted to a display (D19, ARCH §6.14), so
//! the single quantisation stays single.
//! //!
//! **After framing, at render resolution.** The alternative — running detail //! **After framing, at render resolution.** The alternative — running detail
//! on the demosaiced source before the framing prologue — is superficially //! on the demosaiced source before the framing prologue — is superficially
@@ -122,8 +130,6 @@
use std::fmt::Write as _; use std::fmt::Write as _;
use dr_types::ColourSpace;
use crate::operation::{Helper, Operation, Uniform}; use crate::operation::{Helper, Operation, Uniform};
/// Floats the generated detail uniform block always carries, before an /// Floats the generated detail uniform block always carries, before an
@@ -199,6 +205,10 @@ pub const DETAIL_BASE_UNIFORM_FIELDS: usize = 4;
pub struct RenderScale { pub struct RenderScale {
render: (u32, u32), render: (u32, u32),
full: (u32, u32), full: (u32, u32),
/// The whole framed photograph at source resolution: `full` before the
/// zoom and the tile were folded in. What a frame fraction is a fraction
/// of — see [`Self::frame_fraction`].
frame: (u32, u32),
} }
impl RenderScale { impl RenderScale {
@@ -210,9 +220,27 @@ impl RenderScale {
/// [`crate::EditGraph::render_scale`] works both out from the framing, and /// [`crate::EditGraph::render_scale`] works both out from the framing, and
/// is what a caller should normally use. /// is what a caller should normally use.
pub fn new(render: (u32, u32), full: (u32, u32)) -> Self { pub fn new(render: (u32, u32), full: (u32, u32)) -> Self {
let full = (full.0.max(1), full.1.max(1));
Self { Self {
render: (render.0.max(1), render.1.max(1)), render: (render.0.max(1), render.1.max(1)),
full: (full.0.max(1), full.1.max(1)), full,
frame: full,
}
}
/// TRACES: FR-DSP-1 | FR-DSP-2
/// The same scale, for a render that shows only part of a larger frame.
///
/// `frame` is the whole framed photograph at source resolution — the crop
/// folded in, the zoom and any tile not. A zoomed view and an export tile
/// both look at part of the frame, and a clarity radius is a fraction of
/// the *frame*, not of the part: measured against the part, zooming in
/// shrinks the halo to a fraction of what the file will get, and two
/// neighbouring tiles of an export would each draw their own.
pub fn within(self, frame: (u32, u32)) -> Self {
Self {
frame: (frame.0.max(1), frame.1.max(1)),
..self
} }
} }
@@ -261,8 +289,19 @@ impl RenderScale {
/// For the compositional family — clarity, texture, dehaze — and the same /// For the compositional family — clarity, texture, dehaze — and the same
/// unit `dr-gpu`'s mask rasteriser already converts feathers in. An edit /// unit `dr-gpu`'s mask rasteriser already converts feathers in. An edit
/// stored this way is resolution-independent by construction. /// stored this way is resolution-independent by construction.
///
/// Measured against the whole frame ([`Self::within`]), scaled by the
/// render's own short edge over the viewed region's. When the render shows
/// the whole frame the two sizes cancel and this is `fraction` of the
/// render's short edge exactly.
pub fn frame_fraction(&self, fraction: f32) -> f32 { pub fn frame_fraction(&self, fraction: f32) -> f32 {
fraction * self.render.0.min(self.render.1) as f32 let render = self.render.0.min(self.render.1) as f32;
let viewed = self.full.0.min(self.full.1) as f32;
let frame = self.frame.0.min(self.frame.1) as f32;
if self.frame == self.full {
return fraction * render;
}
fraction * render * (frame / viewed)
} }
/// Whether a radius stated in source pixels survives this render. /// Whether a radius stated in source pixels survives this render.
@@ -491,15 +530,6 @@ pub struct ComposedDetailPass {
pub radius: u32, pub radius: u32,
/// See [`DetailPass::output_scale`]. /// See [`DetailPass::output_scale`].
pub output_scale: u32, pub output_scale: u32,
/// Whether this pass writes the display/export texture rather than another
/// linear intermediate.
///
/// True for exactly the last pass in the chain, which carries the output
/// transform — the primaries conversion, the clip and the encode that the
/// fused pass performs when there is no detail stage at all. Folding them
/// into the last pass rather than adding a resolve dispatch keeps the cost
/// of the stage at one dispatch per pass, not one plus one.
pub writes_output: bool,
/// Identifies this pass's *structure*, for the pipeline cache. Covers the /// Identifies this pass's *structure*, for the pipeline cache. Covers the
/// generated source, not the uniform values — so moving a slider uploads a /// generated source, not the uniform values — so moving a slider uploads a
/// buffer and reuses the compiled pipeline, exactly as the fused pass does. /// buffer and reuses the compiled pipeline, exactly as the fused pass does.
@@ -537,6 +567,26 @@ impl ComposedDetail {
.max() .max()
.unwrap_or(0) .unwrap_or(0)
} }
/// TRACES: FR-DSP-2
/// How far the whole chain reads from the pixel it finally writes, in
/// render pixels: the halo a tile has to be grown by so that its interior
/// renders exactly as the untiled frame does.
///
/// The **sum** of the passes' reaches, not the widest of them. The passes
/// run one after another, so a pixel of the last one depends on pixels of
/// the one before it `r` away, each of which depends on pixels a further
/// `r'` away. A separable blur's two halves each reach `r` along one axis
/// and the sum over-counts them by a factor of two; that is the price of a
/// bound that is always safe, and it is paid only by export tiles.
///
/// One pixel per pass on top, for the reduced grids' bilinear taps.
pub fn reach(&self) -> u32 {
self.passes
.iter()
.map(|p| p.radius.saturating_mul(p.output_scale).saturating_add(1))
.fold(0u32, u32::saturating_add)
}
} }
/// TRACES: FR-DEV-3 | FR-DSP-1 /// TRACES: FR-DEV-3 | FR-DSP-1
@@ -547,10 +597,11 @@ impl ComposedDetail {
/// an edit with no sharpening produces an empty chain and `dr-gpu` runs the /// an edit with no sharpening produces an empty chain and `dr-gpu` runs the
/// single dispatch it always did. /// single dispatch it always did.
/// ///
/// `output` is the space the **last** pass encodes into, and it is a parameter /// No pass encodes. Every pass writes a linear intermediate, the last one
/// for the same reason it is a parameter to [`crate::compose_with_framing`]: a /// included, and the fused pass's view pass ([`crate::ComposedShader::view`])
/// screen render and a Display P3 export are the same edit and different /// reads the last and performs the view transform and the output transform
/// shaders, and neither is more authoritative than the other. /// (D19). So the output space is not a parameter here: a screen render and a
/// Display P3 export share one detail stage.
/// ///
/// # The generated uniform block /// # The generated uniform block
/// ///
@@ -562,12 +613,8 @@ impl ComposedDetail {
/// is there because a two-pass operation emitting one body for both directions /// is there because a two-pass operation emitting one body for both directions
/// is a reasonable thing to want, and would otherwise need a uniform of its /// is a reasonable thing to want, and would otherwise need a uniform of its
/// own purely to say which half it is in. /// own purely to say which half it is in.
pub fn compose_detail( pub fn compose_detail(ops: &[Box<dyn Operation>], scale: RenderScale) -> ComposedDetail {
ops: &[Box<dyn Operation>], compose_detail_with(ops, &[], scale)
scale: RenderScale,
output: ColourSpace,
) -> ComposedDetail {
compose_detail_with(ops, &[], scale, output)
} }
/// TRACES: FR-DEV-8 /// TRACES: FR-DEV-8
@@ -592,7 +639,6 @@ pub fn compose_detail_with(
ops: &[Box<dyn Operation>], ops: &[Box<dyn Operation>],
spots: &[DetailPass], spots: &[DetailPass],
scale: RenderScale, scale: RenderScale,
output: ColourSpace,
) -> ComposedDetail { ) -> ComposedDetail {
// Every pass of every active detail operation, flattened, carrying the // Every pass of every active detail operation, flattened, carrying the
// operation it came from for the uniform prefix and the helper set. // operation it came from for the uniform prefix and the helper set.
@@ -624,45 +670,13 @@ pub fn compose_detail_with(
} }
} }
// An active detail operation that emitted nothing at this scale. // An active detail operation may emit nothing at this scale — an
// // acutance operation on a heavy proxy, whose one-source-pixel radius is a
// Legal, and the honest answer for an acutance operation on a heavy proxy // third of a render pixel (see [`RenderScale`]). The chain is then empty
// — a one-source-pixel radius is a third of a render pixel there and no // while the fused pass has stopped at linear working values, and that is
// kernel represents a third of a pixel (see [`RenderScale`]). But it opens // fine: the fused pass's view pass reads the fused result directly and
// a hole between the two halves of the composition: [`compose_full`] // performs the output transform. Before D19 the last detail pass encoded,
// decides to hand on linear working values from the *operations*, which it // and this case needed a body-less resolve pass to do it.
// must, having no scale to consult, so the fused pass has already stopped
// short of the output transform. Returning an empty chain here would leave
// that transform undone and bind an `rgba16float` shader to an
// `rgba8unorm` target, which surfaces as a wgpu validation failure a long
// way from the cause.
//
// So the chain is never empty when the fused pass is expecting one: a
// single pass with no body, which reads the intermediate and performs the
// output transform the fused pass skipped. One dispatch, in the uncommon
// case where a photographer has a kernel switched on at a scale that
// cannot draw it — against the alternative of the preview failing outright
// or `compose_full` growing a resolution argument it has no other use for.
if planned.is_empty() && ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
return ComposedDetail {
passes: vec![compose_one(
RESOLVE_ID,
&[],
&DetailPass {
output_scale: 1,
label: "resolve",
radius: 0,
wgsl: String::new(),
uniforms: Vec::new(),
storage: Vec::new(),
},
0,
scale,
output,
true,
)],
};
}
// TRACES: NFR-P5 // TRACES: NFR-P5
// A pass whose body is empty changes nothing but where the pixels are: it // A pass whose body is empty changes nothing but where the pixels are: it
@@ -674,12 +688,10 @@ pub fn compose_detail_with(
// 2560 x 1600 frame on the reference laptop with its clocks held down. // 2560 x 1600 frame on the reference laptop with its clocks held down.
// //
// Dropped here, where the chain is still a list, and only where dropping // Dropped here, where the chain is still a list, and only where dropping
// it is exact: // it is exact. The last pass is no exception since D19: it writes an
// `rgba16float` intermediate like the others, and the view pass reads
// whichever one the chain last wrote.
// //
// - **Not the last pass.** The last pass performs the output transform on
// what it read from an `rgba16float` intermediate. Moving that transform
// onto the pass before would apply it to that pass's `f32` result
// instead, which is a different rounding of the same picture.
// - **Not after a reduced pass.** A full-resolution pass ends the reduced // - **Not after a reduced pass.** A full-resolution pass ends the reduced
// chain (see `DetailRunner::encode`), so one that follows a scaled pass // chain (see `DetailRunner::encode`), so one that follows a scaled pass
// is what stops the next operation reading the last one's base. None of // is what stops the next operation reading the last one's base. None of
@@ -688,45 +700,29 @@ pub fn compose_detail_with(
// Everywhere else the pass before and the pass after exchange the same // Everywhere else the pass before and the pass after exchange the same
// `rgba16float` texels either way, `aux` included. // `rgba16float` texels either way, `aux` included.
let mut kept: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::with_capacity(planned.len()); let mut kept: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::with_capacity(planned.len());
let total = planned.len(); for entry in planned {
for (position, entry) in planned.into_iter().enumerate() {
let after_full = kept.last().is_none_or(|(_, _, p, _)| p.output_scale <= 1); let after_full = kept.last().is_none_or(|(_, _, p, _)| p.output_scale <= 1);
let droppable = position + 1 < total && after_full && entry.2.is_identity(); let droppable = after_full && entry.2.is_identity();
if !droppable { if !droppable {
kept.push(entry); kept.push(entry);
} }
} }
let planned = kept; let planned = kept;
let last = planned.len().saturating_sub(1);
let passes = planned let passes = planned
.into_iter() .into_iter()
.enumerate() .map(|(id, helpers, pass, index)| compose_one(id, helpers, &pass, index, scale))
.map(|(position, (id, helpers, pass, index))| {
compose_one(id, helpers, &pass, index, scale, output, position == last)
})
.collect(); .collect();
ComposedDetail { passes } ComposedDetail { passes }
} }
/// The operation id the resolve pass is labelled with.
///
/// Not an operation: no `ops/*.yaml` declares it and nothing in the chain
/// answers to it. It exists so the generated label reads `detail/resolve`
/// rather than borrowing the id of whichever operation happened to fall
/// through, which would send a reader looking for a bug in that operation.
const RESOLVE_ID: &str = "detail";
#[allow(clippy::too_many_arguments)]
fn compose_one( fn compose_one(
id: &str, id: &str,
helpers: &[Helper], helpers: &[Helper],
pass: &DetailPass, pass: &DetailPass,
index: usize, index: usize,
scale: RenderScale, scale: RenderScale,
output: ColourSpace,
writes_output: bool,
) -> ComposedDetailPass { ) -> ComposedDetailPass {
let prefix = format!("{}_{index}", crate::operation::sanitise(id)); let prefix = format!("{}_{index}", crate::operation::sanitise(id));
@@ -772,41 +768,13 @@ fn compose_one(
let _ = writeln!(helper_src, "{}\n", h.source.trim_end()); let _ = writeln!(helper_src, "{}\n", h.source.trim_end());
} }
// The storage format and the tail are the *only* difference between an // Every pass writes another linear intermediate: no clip and no encode,
// intermediate pass and the final one. Everything above — the taps, the // because the view pass after the last one still has to read real values
// uniforms, the body — is identical, which is what lets an operation write // (D19). `aux` rides in alpha. A pass that never touches it hands on
// one kernel without knowing whether it happens to be last in the chain. // whatever it was given, so the lane costs an operation that does not want
let (store_format, tail) = if writes_output { // it exactly one copy of a value it already read.
( let store_format = "rgba16float";
"rgba8unorm", let tail = " textureStore(output, coord, vec4<f32>(c, aux));";
format!(
"{} // Clip to the output gamut and encode. The one quantisation\n\
\x20 // the pipeline performs (FR-DEV-2), and it is here rather than\n\
\x20 // in the fused pass because this is now the last thing to run.\n\
\x20 c = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0));\n\
\x20 textureStore(output, coord, vec4<f32>(encode_output(c), 1.0));",
crate::operation::primaries_conversion(output)
),
)
} else {
(
"rgba16float",
" // Another linear intermediate: no clip and no encode, because\n\
\x20 // the pass after this one still has to read real values.\n\
\x20 //\n\
\x20 // `aux` rides in alpha. A pass that never touches it hands on\n\
\x20 // whatever it was given, so the lane costs an operation that\n\
\x20 // does not want it exactly one copy of a value it already read.\n\
\x20 textureStore(output, coord, vec4<f32>(c, aux));"
.to_string(),
)
};
let encode_fn = if writes_output {
crate::operation::encode_output_fn(output)
} else {
String::new()
};
let label = format!("{id}/{}", pass.label); let label = format!("{id}/{}", pass.label);
let indented = body let indented = body
@@ -823,7 +791,7 @@ fn compose_one(
// way back to a coordinate. // way back to a coordinate.
// //
// In: linear sRGB, scene-referred, **unclipped**, at render resolution. // In: linear sRGB, scene-referred, **unclipped**, at render resolution.
// Out: {} // Out: the same, for the next pass or for the view pass after the last.
struct Params {{ struct Params {{
{uniform_fields}}} {uniform_fields}}}
@@ -907,7 +875,7 @@ fn reduced_at(coord: vec2<i32>) -> f32 {{
return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y); return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y);
}} }}
{helper_src}{encode_fn} {helper_src}
@compute @workgroup_size(8, 8, 1) @compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
let dims = textureDimensions(output); let dims = textureDimensions(output);
@@ -936,12 +904,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
{tail} {tail}
}} }}
", "
if writes_output {
"display-encoded, in the output space."
} else {
"linear sRGB, for the next pass."
},
); );
let structure_hash = crate::operation::hash_source(&source); let structure_hash = crate::operation::hash_source(&source);
@@ -956,7 +919,6 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
// dispatch size and a declaration is data, which since FR-PLG-2 can // dispatch size and a declaration is data, which since FR-PLG-2 can
// come from a file this build did not write. // come from a file this build did not write.
output_scale: pass.output_scale.max(1), output_scale: pass.output_scale.max(1),
writes_output,
structure_hash, structure_hash,
} }
} }
@@ -1013,6 +975,21 @@ mod tests {
assert!((export.frame_fraction(0.01) - 40.0).abs() < 0.5); assert!((export.frame_fraction(0.01) - 40.0).abs() < 0.5);
} }
#[test]
fn a_frame_fraction_does_not_shrink_with_the_zoom_or_the_tile() {
// TRACES: FR-DSP-1 | FR-DSP-2
// A 6000×4000 frame. At fit in a 1500×1000 panel, 1% of it is 10
// render pixels; zoomed to 1:1 on a 1500×1000 corner of it, the same
// 1% is 40 — the 40 the file gets — and an export tile of that corner
// must say 40 too, or each tile draws its own halo and the seams show.
let fit = RenderScale::new((1500, 1000), (6000, 4000));
assert!((fit.frame_fraction(0.01) - 10.0).abs() < 1e-3);
let zoomed = RenderScale::new((1500, 1000), (1500, 1000)).within((6000, 4000));
assert!((zoomed.frame_fraction(0.01) - 40.0).abs() < 1e-3);
let tile = RenderScale::full((1024, 1024)).within((6000, 4000));
assert!((tile.frame_fraction(0.01) - 40.0).abs() < 1e-3);
}
#[test] #[test]
fn zooming_to_one_to_one_makes_the_preview_exact() { fn zooming_to_one_to_one_makes_the_preview_exact() {
// The reason there is no separate full-resolution preview path: the // The reason there is no separate full-resolution preview path: the
@@ -1095,27 +1072,19 @@ mod tests {
// dispatch. An unedited photograph must not pay for a sharpener it is // dispatch. An unedited photograph must not pay for a sharpener it is
// not using. // not using.
let ops = with_blur(0.0); let ops = with_blur(0.0);
let composed = compose_detail( let composed = compose_detail(&ops, RenderScale::full((512, 512)));
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
);
assert!(composed.is_empty()); assert!(composed.is_empty());
assert_eq!(fused(&ops).output_mode, OutputMode::Encoded); assert_eq!(fused(&ops).output_mode, OutputMode::Encoded);
} }
#[test] #[test]
fn a_separable_blur_becomes_two_passes_and_only_the_last_encodes() { fn a_separable_blur_becomes_two_passes_and_neither_encodes() {
// The multi-pass case, which is the one the ping-pong exists for. The // The multi-pass case, which is the one the ping-pong exists for. Both
// first pass writes a linear intermediate and the second writes the // passes write linear intermediates, and the fused pass's view pass
// display texture — so the output transform happens exactly once, at // reads the second and performs the view transform and the output
// the end, wherever the end happens to be. // transform — so those happen exactly once, after every kernel (D19).
let ops = with_blur(0.05); let ops = with_blur(0.05);
let composed = compose_detail( let composed = compose_detail(&ops, RenderScale::full((512, 512)));
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
);
assert_eq!(composed.len(), 2); assert_eq!(composed.len(), 2);
let first = &composed.passes[0]; let first = &composed.passes[0];
@@ -1123,13 +1092,16 @@ mod tests {
assert_eq!(first.label, "detail_probe/horizontal"); assert_eq!(first.label, "detail_probe/horizontal");
assert_eq!(last.label, "detail_probe/vertical"); assert_eq!(last.label, "detail_probe/vertical");
assert!(!first.writes_output); for pass in [first, last] {
assert!(first.source.contains("texture_storage_2d<rgba16float")); assert!(pass.source.contains("texture_storage_2d<rgba16float"));
assert!(!first.source.contains("fn encode_output")); assert!(!pass.source.contains("fn encode_output"));
assert!(!pass.source.contains("view_sigmoid"));
assert!(last.writes_output); }
assert!(last.source.contains("texture_storage_2d<rgba8unorm")); let view = fused(&ops)
assert!(last.source.contains("fn encode_output")); .view
.expect("a view pass follows the detail stage");
assert!(view.source.contains("fn encode_output"));
assert!(view.source.contains("c = view_sigmoid("));
// Two passes of one operation are two shaders, so they must not share // Two passes of one operation are two shaders, so they must not share
// a pipeline-cache entry — the classic way a second pass silently runs // a pipeline-cache entry — the classic way a second pass silently runs
@@ -1143,11 +1115,7 @@ mod tests {
// and the composer rewrites it to a prefixed struct field, so two // and the composer rewrites it to a prefixed struct field, so two
// operations may both call a uniform `radius` and neither has to know. // operations may both call a uniform `radius` and neither has to know.
let ops = with_blur(0.05); let ops = with_blur(0.05);
let composed = compose_detail( let composed = compose_detail(&ops, RenderScale::full((512, 512)));
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
);
let src = &composed.passes[0].source; let src = &composed.passes[0].source;
assert!(src.contains("detail_probe_0_radius: f32,")); assert!(src.contains("detail_probe_0_radius: f32,"));
assert!(src.contains("let r = i32(u.detail_probe_0_radius);")); assert!(src.contains("let r = i32(u.detail_probe_0_radius);"));
@@ -1164,13 +1132,7 @@ mod tests {
// outright by the WGSL uniform address space rules, and the failure // outright by the WGSL uniform address space rules, and the failure
// arrives as a shader compilation error against generated source. // arrives as a shader compilation error against generated source.
let ops = with_blur(0.05); let ops = with_blur(0.05);
for pass in compose_detail( for pass in compose_detail(&ops, RenderScale::full((512, 512))).passes {
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
)
.passes
{
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label); assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
assert!(pass.uniforms.iter().all(|v| v.is_finite())); assert!(pass.uniforms.iter().all(|v| v.is_finite()));
// The base block is first and fixed, so a pass never addresses a // The base block is first and fixed, so a pass never addresses a
@@ -1189,7 +1151,7 @@ mod tests {
// the truth rather than zero. // the truth rather than zero.
let ops = with_blur(0.05); let ops = with_blur(0.05);
let scale = RenderScale::full((400, 400)); let scale = RenderScale::full((400, 400));
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb); let composed = compose_detail(&ops, scale);
let expected = BoxBlur::with_radius(0.05).kernel(scale); let expected = BoxBlur::with_radius(0.05).kernel(scale);
assert_eq!(expected, 20, "5% of a 400px edge"); assert_eq!(expected, 20, "5% of a 400px edge");
assert_eq!(composed.radius(), expected); assert_eq!(composed.radius(), expected);
@@ -1208,7 +1170,7 @@ mod tests {
.iter() .iter()
.map(|&(w, h)| { .map(|&(w, h)| {
let scale = RenderScale::full((w, h)); let scale = RenderScale::full((w, h));
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb); let composed = compose_detail(&ops, scale);
composed.radius() as f32 / w.min(h) as f32 composed.radius() as f32 / w.min(h) as f32
}) })
.collect(); .collect();
@@ -1226,14 +1188,14 @@ mod tests {
// cannot see each other. `compose_full` decides to hand on linear // cannot see each other. `compose_full` decides to hand on linear
// working values from the *operations* — it has no resolution to // working values from the *operations* — it has no resolution to
// consult — while this composer converts a radius and can legitimately // consult — while this composer converts a radius and can legitimately
// decide there is nothing to draw at this size. An empty chain would // decide there is nothing to draw at this size.
// then leave the output transform undone: the fused pass writes
// `rgba16float` and the frontend binds an `rgba8unorm` target to it.
// //
// A photographer meets this by turning on capture sharpening or // A photographer meets this by turning on capture sharpening or
// luminance noise reduction while the develop view is fitted to a // luminance noise reduction while the develop view is fitted to a
// large file, which is the normal way to work, so it is not an edge // large file, which is the normal way to work. Before D19 an empty
// case that can be left to fail. // chain left the output transform undone and needed a resolve pass;
// now the view pass does the output transform whatever the chain
// holds.
let ops = with_blur(0.001); let ops = with_blur(0.001);
let scale = RenderScale::full((400, 400)); let scale = RenderScale::full((400, 400));
assert!(ops.last().expect("the blur").is_active()); assert!(ops.last().expect("the blur").is_active());
@@ -1243,74 +1205,59 @@ mod tests {
"the premise: a radius too small to draw emits no pass" "the premise: a radius too small to draw emits no pass"
); );
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb); // Empty, and that is fine since D19: nothing in the chain encodes, so
assert_eq!(composed.len(), 1, "the chain must not be empty here"); // there is no output transform for an empty chain to leave undone.
assert_eq!(composed.radius(), 0, "it reads only the pixel it writes"); // The fused pass stopped at linear values and its view pass reads
// them directly.
let resolve = &composed.passes[0]; let composed = compose_detail(&ops, scale);
assert_eq!(resolve.label, "detail/resolve"); assert!(composed.is_empty());
assert!(resolve.writes_output); let fused = fused(&ops);
assert!(resolve.source.contains("texture_storage_2d<rgba8unorm")); assert_eq!(fused.output_mode, OutputMode::LinearWorking);
assert!(resolve.source.contains("fn encode_output")); let view = fused
// Exactly the fixed base block and no more: a pass with no body has .view
// nothing of its own to upload, and the block still has to be a .expect("the view pass performs the output transform");
// multiple of sixteen bytes. assert_eq!(view.output_mode, OutputMode::Encoded);
assert_eq!(resolve.uniforms.len(), DETAIL_BASE_UNIFORM_FIELDS); assert!(view.source.contains("fn encode_output"));
assert_eq!(resolve.uniforms.len() % 4, 0);
// And it really is a copy: the fused pass composed alongside it is the
// one that stopped short, so the two agree about who encodes.
assert_eq!(fused(&ops).output_mode, OutputMode::LinearWorking);
} }
#[test] #[test]
fn a_pass_that_changes_nothing_is_dropped_where_that_is_exact() { fn a_pass_that_changes_nothing_is_dropped_where_that_is_exact() {
// TRACES: NFR-P5 // TRACES: NFR-P5
// Capture sharpening at a scale too coarse to draw its radius emits a // A pass with an empty body costs a render-sized read and write and
// pass with an empty body. Between two other passes it costs a // changes no texel, so it goes — wherever it falls since D19, the last
// render-sized read and write and changes no texel, so it goes; as the // position included, because the last pass writes an intermediate like
// last pass it performs the output transform on the intermediate, and // every other and the view pass reads whichever the chain last wrote.
// moving that onto the pass before would round differently, so it // Built by hand, and run as a repair so it goes first: no operation
// stays. // emits one any more (capture sharpening at a scale too coarse to draw
use crate::ops::{capture_sharpen, CaptureSharpen, NoiseReduction}; // its radius used to, and now emits nothing).
let sharpen = || -> Box<dyn Operation> { use crate::ops::NoiseReduction;
let mut op = CaptureSharpen::new();
op.set_param(capture_sharpen::AMOUNT, 60.0);
Box::new(op)
};
let chroma = || -> Box<dyn Operation> { Box::new(NoiseReduction::with_amounts(0.0, 60.0)) }; let chroma = || -> Box<dyn Operation> { Box::new(NoiseReduction::with_amounts(0.0, 60.0)) };
// A 24 MP frame fitted to a panel: a one-source-pixel radius is a
// quarter of a render pixel.
let scale = RenderScale::new((1500, 1000), (6000, 4000)); let scale = RenderScale::new((1500, 1000), (6000, 4000));
let unresolved = sharpen().detail().expect("a detail stage").passes(scale); let nothing = DetailPass {
assert!( output_scale: 1,
unresolved.len() == 1 && unresolved[0].is_identity(), label: "nothing",
"the premise: sharpening at this scale is one pass that does nothing" radius: 0,
); wgsl: "// `c` already holds this pixel.".to_string(),
let labels = |ops: &[Box<dyn Operation>]| -> Vec<String> { uniforms: Vec::new(),
compose_detail(ops, scale, dr_types::ColourSpace::Srgb) storage: Vec::new(),
};
assert!(nothing.is_identity(), "the premise");
let labels: Vec<String> =
compose_detail_with(&[chroma()], std::slice::from_ref(&nothing), scale)
.passes .passes
.iter() .iter()
.map(|p| p.label.clone()) .map(|p| p.label.clone())
.collect() .collect();
};
// First, ahead of the chroma passes: dropped.
let first = labels(&[sharpen(), chroma()]);
assert_eq!( assert_eq!(
first, labels,
[ [
"noise_reduction/chroma-horizontal", "noise_reduction/chroma-horizontal",
"noise_reduction/chroma-vertical" "noise_reduction/chroma-vertical"
] ]
); );
// Last, after them: kept, and it is the pass that encodes. // Alone: dropped too, and the chain is empty — the view pass
let last = labels(&[chroma(), sharpen()]); // finishes the frame.
assert_eq!(last.len(), 3); assert!(compose_detail_with(&[], &[nothing], scale).is_empty());
assert_eq!(last[2], "capture_sharpen/unresolved");
// Alone: kept, because the fused pass stopped short and something has
// to finish the frame.
assert_eq!(labels(&[sharpen()]), ["capture_sharpen/unresolved"]);
} }
#[test] #[test]
@@ -1325,11 +1272,7 @@ mod tests {
// A pass that says nothing about `aux` hands on what it was given, // A pass that says nothing about `aux` hands on what it was given,
// which is why the box blur below needs no knowledge of it. // which is why the box blur below needs no knowledge of it.
let ops = with_blur(0.05); let ops = with_blur(0.05);
let composed = compose_detail( let composed = compose_detail(&ops, RenderScale::full((512, 512)));
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
);
for pass in &composed.passes { for pass in &composed.passes {
assert!( assert!(
@@ -1344,11 +1287,12 @@ mod tests {
.contains("textureStore(output, coord, vec4<f32>(c, aux));"), .contains("textureStore(output, coord, vec4<f32>(c, aux));"),
"an intermediate must carry the lane to the pass after it" "an intermediate must carry the lane to the pass after it"
); );
// The last pass writes the display texture, whose alpha is opacity and // The last pass carries it too: since D19 it writes an intermediate
// not scratch space. Readable there, not written — which is the right // for the view pass rather than the display texture, whose alpha is
// way round, because the combining pass is the one that reads it. // opacity. The view pass reads only the colour.
assert!(composed.passes[1].writes_output); assert!(composed.passes[1]
assert!(!composed.passes[1].source.contains("vec4<f32>(c, aux)")); .source
.contains("textureStore(output, coord, vec4<f32>(c, aux));"));
} }
#[test] #[test]
@@ -1357,11 +1301,7 @@ mod tests {
// overwhelmingly common edit: no sharpening means no chain, which // overwhelmingly common edit: no sharpening means no chain, which
// means `dr-gpu` runs the single fused dispatch it always did. // means `dr-gpu` runs the single fused dispatch it always did.
let ops = crate::ops::chain(); let ops = crate::ops::chain();
let composed = compose_detail( let composed = compose_detail(&ops, RenderScale::full((64, 64)));
&ops,
RenderScale::full((64, 64)),
dr_types::ColourSpace::Srgb,
);
assert!(composed.is_empty()); assert!(composed.is_empty());
assert_eq!(composed.radius(), 0); assert_eq!(composed.radius(), 0);
} }
+4 -2
View File
@@ -1298,7 +1298,8 @@ impl Framing {
// count active stages, and a neutral graph must generate none. // count active stages, and a neutral graph must generate none.
if !self.is_active() { if !self.is_active() {
return " // Source position, normalised and centred: the whole frame, unrotated. return " // Source position, normalised and centred: the whole frame, unrotated.
let src_dims = textureDimensions(source); let tex_dims = textureDimensions(source);
let src_dims = select(tex_dims, vec2<u32>(u.source_full.xy), u.source_full.x > 0.5);
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0); let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims); let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
var p = (uv - vec2<f32>(0.5)) * aspect; var p = (uv - vec2<f32>(0.5)) * aspect;
@@ -1314,7 +1315,8 @@ impl Framing {
// warp chain expects: the centre is (0, 0) and the radius is 1 at the // warp chain expects: the centre is (0, 0) and the radius is 1 at the
// corner. Working here rather than in pixels is what makes the map // corner. Working here rather than in pixels is what makes the map
// independent of the resolution being rendered at. // independent of the resolution being rendered at.
let src_dims = textureDimensions(source); let tex_dims = textureDimensions(source);
let src_dims = select(tex_dims, vec2<u32>(u.source_full.xy), u.source_full.x > 0.5);
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0); let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims); var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
", ",
+90 -27
View File
@@ -83,6 +83,13 @@ impl ParamCapability {
} }
} }
/// TRACES: FR-DSP-2
/// How far past the framing's own footprint [`EditGraph::source_region`]
/// reaches when a lens warp is active, as a fraction of the frame on each
/// side. Distortion profiles move a corner by a few per cent of the frame; a
/// window short of what the warp reads would render the missing strip black.
pub const WARP_MARGIN: f32 = 0.04;
/// An ordered pipeline of operations, plus how the result is framed. /// An ordered pipeline of operations, plus how the result is framed.
pub struct EditGraph { pub struct EditGraph {
ops: Vec<Box<dyn Operation>>, ops: Vec<Box<dyn Operation>>,
@@ -292,6 +299,57 @@ impl EditGraph {
self.framing.output_size(width, height) self.framing.output_size(width, height)
} }
/// TRACES: FR-DSP-2 | NFR-RES-2
/// The part of the source the visible region reads, as a rectangle in
/// normalised source coordinates, clamped to the frame.
///
/// For a photograph larger than one texture: a render of part of it —
/// the canvas zoomed in, one tile of an export — binds only this window
/// of the source (see `dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS`).
///
/// The framing is walked on the CPU with [`Framing::source_at`], along
/// the border and across the interior, so a straightened or keystoned
/// view gets the box around the quadrilateral it actually reads. The lens
/// warps have no CPU mirror, so when one is active the box is widened
/// by [`WARP_MARGIN`] of the frame on each side: a distortion profile
/// moves a corner by a few per cent of the frame at most. `halo`, in
/// source pixels, is added on top — the detail stage's reach, which reads
/// beyond the pixels it writes.
pub fn source_region(&self, source: (u32, u32), halo: u32) -> crate::framing::CropRect {
const STEPS: usize = 16;
let (sw, sh) = (source.0.max(1), source.1.max(1));
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for j in 0..=STEPS {
for i in 0..=STEPS {
let out = (i as f32 / STEPS as f32, j as f32 / STEPS as f32);
let (x, y) = self.framing.source_at(out, sw, sh);
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
}
let warp = if crate::lens::compose_warps(&self.warps).is_active() {
WARP_MARGIN
} else {
0.0
};
// Two pixels beyond the halo: the bilinear tap's second texel, and
// the rounding of the box to whole pixels by the caller.
let px = (halo as f32 + 2.0) / sw as f32;
let py = (halo as f32 + 2.0) / sh as f32;
let x0 = (x0 - warp - px).clamp(0.0, 1.0);
let y0 = (y0 - warp - py).clamp(0.0, 1.0);
let x1 = (x1 + warp + px).clamp(0.0, 1.0);
let y1 = (y1 + warp + py).clamp(0.0, 1.0);
crate::framing::CropRect {
x: x0,
y: y0,
width: (x1 - x0).max(0.0),
height: (y1 - y0).max(0.0),
}
}
/// Descriptors for every operation, in order. /// Descriptors for every operation, in order.
/// ///
/// Operations only — framing is not one, and is reached through /// Operations only — framing is not one, and is reached through
@@ -623,7 +681,9 @@ impl EditGraph {
} = self; } = self;
EditState { EditState {
params: Preset::capture(self), // Without the film: it has a field of its own below, and one
// edit must not have two places to disagree about its stock.
params: Preset::capture_params(self),
// A refcount bump. See `EditState::masks` for why that matters on // A refcount bump. See `EditState::masks` for why that matters on
// a path called once a frame. // a path called once a frame.
masks: Arc::clone(masks), masks: Arc::clone(masks),
@@ -663,7 +723,11 @@ impl EditGraph {
// At full scope. `Scope` is a question about what a paste carries // At full scope. `Scope` is a question about what a paste carries
// *between* photographs; this is one photograph's own edit being put // *between* photographs; this is one photograph's own edit being put
// back, so there is nothing to leave behind. // back, so there is nothing to leave behind.
params.apply(self, Scope::everything()); //
// What the parameters say about the film is discarded: the stock is
// `film`'s to decide, below, and clearing it is what happens there
// either way.
let _ = params.apply(self, Scope::everything());
self.masks = Arc::clone(masks); self.masks = Arc::clone(masks);
self.spots = spots.clone(); self.spots = spots.clone();
@@ -946,46 +1010,35 @@ impl EditGraph {
((fw as f32 * view.width).round() as u32).max(1), ((fw as f32 * view.width).round() as u32).max(1),
((fh as f32 * view.height).round() as u32).max(1), ((fh as f32 * view.height).round() as u32).max(1),
); );
crate::detail::RenderScale::new(render, full) crate::detail::RenderScale::new(render, full).within((fw, fh))
} }
/// TRACES: FR-DEV-3 | FR-DSP-1 /// TRACES: FR-DEV-3 | FR-DSP-1
/// Generate the detail stage for this edit at one resolution, to sRGB. /// Generate the detail stage for this edit at one resolution.
/// ///
/// Empty for every edit with no active neighbourhood operation, which is /// Empty for every edit with no active neighbourhood operation, which is
/// almost all of them — and in that case [`Self::compose`] emits the /// almost all of them — and in that case [`Self::compose`] emits the
/// single encoded dispatch it always has. /// single encoded dispatch it always has.
pub fn compose_detail(
&self,
source: (u32, u32),
render: (u32, u32),
) -> crate::detail::ComposedDetail {
self.compose_detail_for(source, render, dr_types::ColourSpace::Srgb)
}
/// TRACES: FR-EXP-2
/// The detail stage, encoded into a chosen output space.
/// ///
/// The space belongs here as well as on [`Self::compose_for`] because when /// No output space: since D19 no detail pass encodes. The fused pass's
/// a detail stage exists it is the *last* pass that performs the output /// view pass reads what the last one wrote and performs the view transform
/// transform — the fused pass stops at linear working values. Composing /// and the output transform, so it is [`Self::compose_for`] alone that
/// the two halves for different spaces would encode the edit twice, or /// names the space.
/// not at all. ///
/// `source` is the demosaiced image's size and `render` the size being /// `source` is the demosaiced image's size and `render` the size being
/// drawn. The scale is worked out here rather than handed in, because the /// drawn. The scale is worked out here rather than handed in, because the
/// repairs need the *source* size as well — a spot is stored in normalised /// repairs need the *source* size as well — a spot is stored in normalised
/// source coordinates and has to be put through the framing to find out /// source coordinates and has to be put through the framing to find out
/// where it lands on this render, and a [`crate::detail::RenderScale`] /// where it lands on this render, and a [`crate::detail::RenderScale`]
/// describes the region on screen rather than the photograph. /// describes the region on screen rather than the photograph.
pub fn compose_detail_for( pub fn compose_detail(
&self, &self,
source: (u32, u32), source: (u32, u32),
render: (u32, u32), render: (u32, u32),
output: dr_types::ColourSpace,
) -> crate::detail::ComposedDetail { ) -> crate::detail::ComposedDetail {
let scale = self.render_scale(source, render); let scale = self.render_scale(source, render);
let spots = self.spots.passes(&self.framing, source, scale); let spots = self.spots.passes(&self.framing, source, scale);
crate::detail::compose_detail_with(&self.ops, &spots, scale, output) crate::detail::compose_detail_with(&self.ops, &spots, scale)
} }
/// TRACES: FR-DEV-3d /// TRACES: FR-DEV-3d
@@ -1116,13 +1169,21 @@ mod tests {
fn a_fresh_graph_is_neutral() { fn a_fresh_graph_is_neutral() {
// Opening an unedited image must produce the image, not an // Opening an unedited image must produce the image, not an
// interpretation of it. // interpretation of it.
//
// One block, and it is the view transform: a view operation is
// composed at its defaults, because a photograph with no view
// transform is a scan rather than a picture (FR-DEV-3j). It is still
// neutral in the sense that matters here — nothing moved, nothing is
// written — and every adjustment is absent.
let g = EditGraph::default_chain(); let g = EditGraph::default_chain();
assert!(g.is_neutral()); assert!(g.is_neutral());
let source = g.compose().source;
assert_eq!( assert_eq!(
g.compose().source.matches("---- ").count(), source.matches("---- ").count(),
0, 1,
"a neutral graph must generate no operation blocks" "a neutral graph must generate no adjustment blocks"
); );
assert!(source.contains("---- view_transform ----"));
} }
#[test] #[test]
@@ -1201,13 +1262,15 @@ mod tests {
#[test] #[test]
fn only_active_operations_reach_the_shader() { fn only_active_operations_reach_the_shader() {
// The composition property, end to end: two adjustments out of seven // The composition property, end to end: two adjustments out of seven
// available must generate a shader doing exactly two things. // available must generate a shader doing exactly two things — and
// the view transform, which every render has (FR-DEV-3j).
let mut g = EditGraph::default_chain(); let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
g.set_param(white_balance::ID, white_balance::TINT, 25.0); g.set_param(white_balance::ID, white_balance::TINT, 25.0);
let shader = g.compose(); let shader = g.compose();
assert_eq!(shader.source.matches("---- ").count(), 2); assert_eq!(shader.source.matches("---- ").count(), 3);
assert!(shader.source.contains("---- view_transform ----"));
assert!(shader.source.contains("---- exposure ----")); assert!(shader.source.contains("---- exposure ----"));
assert!(shader.source.contains("---- white_balance ----")); assert!(shader.source.contains("---- white_balance ----"));
assert!(!shader.source.contains("---- saturation ----")); assert!(!shader.source.contains("---- saturation ----"));
+3 -1
View File
@@ -581,7 +581,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8], lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0, density_max: 2.0,
lut_size: 2, lut_size: 2,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [2.0; 3], grain_density_max: [2.0; 3],
grain_uniformity: 1.0, grain_uniformity: 1.0,
}, },
+29 -7
View File
@@ -32,6 +32,7 @@
//! single multiply and white balance a per-channel scale; on gamma-encoded //! single multiply and white balance a per-channel scale; on gamma-encoded
//! data neither would be physically meaningful (ARCH §5.2). //! data neither would be physically meaningful (ARCH §5.2).
pub mod bundled;
pub mod coverage; pub mod coverage;
pub mod declared; pub mod declared;
pub mod descriptor; pub mod descriptor;
@@ -48,8 +49,9 @@ pub mod orphan;
pub mod preset; pub mod preset;
pub mod sidecar; pub mod sidecar;
pub mod spot; pub mod spot;
pub mod starter;
pub mod state; pub mod state;
pub mod tiles;
pub mod view;
pub use coverage::Coverage; pub use coverage::Coverage;
pub use declared::{Declaration, DeclaredOp}; pub use declared::{Declaration, DeclaredOp};
@@ -66,10 +68,10 @@ pub use history::{Edit, Entry as HistoryEntry, History, Step};
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp}; pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
pub use operation::{ pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation, compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET, OutputMode, Stage, Uniform, CLIP_ONSET, RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET, SOURCE_WINDOW_UNIFORM_FIELDS, SOURCE_WINDOW_UNIFORM_OFFSET, WHOLE_SOURCE_WINDOW,
}; };
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope}; pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Reach, Scope};
pub use sidecar::{Sidecar, Version}; pub use sidecar::{Sidecar, Version};
pub use spot::{Spot, SpotMode, SpotSet}; pub use spot::{Spot, SpotMode, SpotSet};
pub use state::{EditState, FilmRebake, FilmRef}; pub use state::{EditState, FilmRebake, FilmRef};
@@ -132,7 +134,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0, density_max: 3.0,
lut_size: 32, lut_size: 32,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [3.0; 3], grain_density_max: [3.0; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
}, },
@@ -167,7 +171,21 @@ mod tests {
let mut fused_blocks = 0; let mut fused_blocks = 0;
for desc in g.descriptors() { for desc in g.descriptors() {
let id = desc.id.0; let id = desc.id.0;
let point = shader.source.contains(&format!("---- {id} ----")); // The film is loaded here, and a stock is a rendering: the view
// transform it replaces is correctly in neither stage (FR-DEV-3f,
// FR-DEV-3j).
if id == crate::ops::view_transform::ID.0 {
assert!(!shader.source.contains("---- view_transform ----"));
continue;
}
// A view operation is in the view pass when a detail stage
// follows, which it does here (D19).
let block = format!("---- {id} ----");
let point = shader.source.contains(&block)
|| shader
.view
.as_ref()
.is_some_and(|v| v.source.contains(&block));
let neighbourhood = detail let neighbourhood = detail
.passes .passes
.iter() .iter()
@@ -180,8 +198,12 @@ mod tests {
); );
fused_blocks += usize::from(point); fused_blocks += usize::from(point);
} }
let view_blocks = shader
.view
.as_ref()
.map_or(0, |v| v.source.matches("---- ").count());
assert_eq!( assert_eq!(
shader.source.matches("---- ").count(), shader.source.matches("---- ").count() + view_blocks,
fused_blocks, fused_blocks,
"the fused shader carries a block nothing in the chain asked for" "the fused shader carries a block nothing in the chain asked for"
); );
+407 -89
View File
@@ -73,7 +73,7 @@ use std::fmt::Write as _;
use std::sync::Arc; use std::sync::Arc;
use crate::coverage::Coverage; use crate::coverage::Coverage;
use crate::descriptor::{Attribute, OpDescriptor, ParamId}; use crate::descriptor::{Attribute, OpDescriptor, ParamId, ParamKind};
use crate::operation::Operation; use crate::operation::Operation;
use crate::ops; use crate::ops;
@@ -1368,17 +1368,19 @@ pub struct MaskLayer {
/// This layer's adjustments. /// This layer's adjustments.
/// ///
/// A full chain, the same one [`crate::EditGraph`] holds. That is the /// A full chain, the same one [`crate::EditGraph`] holds. That is the
/// whole reason local adjustments need no per-operation support: the /// whole reason local adjustments need no per-operation support: each
/// composer already knows how to turn a chain into WGSL, and a mask layer /// setting here is an offset from its default, added to the global chain's
/// is a chain that happens to be multiplied by a mask afterwards. /// setting and run where that operation runs, weighted by the mask (see
/// [`offset_onto`]).
pub ops: Vec<Box<dyn Operation>>, pub ops: Vec<Box<dyn Operation>>,
} }
/// The chain a mask layer holds: every point operation, and neither the /// The chain a mask layer holds: every point operation, and neither the
/// neighbourhood ones nor the optical corrections. /// neighbourhood ones nor the optical corrections.
/// ///
/// A layer's adjustments are fused into the colour dispatch and multiplied by /// A layer's adjustments are fused into the colour dispatch, each beside the
/// the mask afterwards, which is exactly why a layer needs no per-operation /// global operation it offsets and weighted by the mask, which is exactly why
/// a layer needs no per-operation
/// support — the composer already knows how to turn a chain into WGSL. A /// support — the composer already knows how to turn a chain into WGSL. A
/// neighbourhood operation cannot go through that path at all: it runs as its /// neighbourhood operation cannot go through that path at all: it runs as its
/// own dispatch in [`crate::detail`], after the fused pass and after the masks /// own dispatch in [`crate::detail`], after the fused pass and after the masks
@@ -1642,8 +1644,8 @@ impl MaskLayer {
/// The operations in this layer's chain that reach the shader. /// The operations in this layer's chain that reach the shader.
/// ///
/// Neighbourhood operations are excluded, and not as an oversight. A /// Neighbourhood operations are excluded, and not as an oversight. A
/// layer's chain is *fused into the point-operation pass* and multiplied /// layer's chain is *fused into the point-operation pass*, weighted by the
/// by the mask afterwards; the detail stage runs once, over the whole /// mask at each operation; the detail stage runs once, over the whole
/// frame, after that pass has finished (see [`crate::detail`]). There is /// frame, after that pass has finished (see [`crate::detail`]). There is
/// nowhere in that arrangement for a sharpening confined to one mask to /// nowhere in that arrangement for a sharpening confined to one mask to
/// happen, so a detail operation in a layer would contribute an empty /// happen, so a detail operation in a layer would contribute an empty
@@ -1654,7 +1656,7 @@ impl MaskLayer {
self.ops self.ops
.iter() .iter()
.map(|o| o.as_ref()) .map(|o| o.as_ref())
.filter(|o| o.is_active() && o.detail().is_none()) .filter(|o| moves(*o) && o.detail().is_none())
} }
/// Whether any part of this mask belongs to a different segmentation. /// Whether any part of this mask belongs to a different segmentation.
@@ -1975,7 +1977,9 @@ impl MaskStack {
Some(self.layers.remove(i)) Some(self.layers.remove(i))
} }
/// Reorder, since later layers composite over earlier ones. /// Reorder. Layers add their changes, so order no longer decides the
/// picture — but it is the order the panel lists them in and the order
/// their slots are assigned.
pub fn move_to(&mut self, id: &str, index: usize) { pub fn move_to(&mut self, id: &str, index: usize) {
let Some(from) = self.layers.iter().position(|l| l.id == id) else { let Some(from) = self.layers.iter().position(|l| l.id == id) else {
return; return;
@@ -2041,25 +2045,104 @@ impl MaskStack {
} }
} }
/// One layer's contribution to the generated shader. /// The layers' contribution to the generated shader.
///
/// Not a block of its own any more. A layer's adjustments are *offsets to the
/// global ones*, applied at each operation's own place in the chain, so what
/// this hands back is pieces the composer threads through its loop over the
/// global operations: the weights, sampled once before the first operation,
/// and one [`LocalOp`] per layer per operation the layer moved.
pub(crate) struct LayerShader { pub(crate) struct LayerShader {
pub uniform_fields: String, pub uniform_fields: String,
pub uniform_values: Vec<f32>, pub uniform_values: Vec<f32>,
pub body: String, /// Each layer's shaped mask, `mask_w{slot}`, sampled once ahead of the
/// operations that read it. Empty when no layer changes a pixel.
pub weights: String,
/// Every layer's version of every operation it moved, in layer order and
/// then chain order — see [`LocalOp`].
pub ops: Vec<LocalOp>,
pub helpers: Vec<crate::operation::Helper>, pub helpers: Vec<crate::operation::Helper>,
/// TRACES: FR-DEV-19c /// TRACES: FR-DEV-19c
/// The block that draws one layer's mask over the finished picture, empty /// The block that draws one layer's mask over the finished picture, empty
/// when nothing is being revealed. /// when nothing is being revealed.
/// ///
/// Kept apart from `body` because it belongs at the other end of the /// Kept apart from the rest because it belongs at the other end of the
/// shader. Everything in `body` runs on scene-referred colour in the /// shader. Everything else runs on scene-referred colour in the working
/// working space, where a flat tint would then be pushed through the base /// space, where a flat tint would then be pushed through the view
/// curve and the camera matrix and arrive as some other colour, and a /// transform and arrive as some other colour, and a
/// white-on-black alpha would arrive as neither. This runs after the /// white-on-black alpha would arrive as neither. This runs after the
/// output transform, so what is written is what is seen. /// output transform, so what is written is what is seen.
pub reveal: String, pub reveal: String,
} }
/// One layer's version of one operation: the global settings with the layer's
/// offsets added, as a fragment reading this layer's own uniforms.
///
/// The composer runs it beside the global fragment on the same input colour,
/// and moves the pixel toward its result by the layer's weight — see
/// `operation::local_block`.
pub(crate) struct LocalOp {
pub op: &'static str,
pub slot: usize,
/// Empty when the offsets cancel the global setting back to neutral. That
/// is still an entry, because it still means something: inside the mask
/// this operation does nothing at all.
///
/// Empty, too, for an operation blended as settings: that entry stands
/// for this layer's uniforms, `mask{slot}_{op}_*`, which the composer
/// averages into the one fragment it runs.
pub fragment: String,
}
/// Whether a layer's copy of `op` holds an adjustment.
///
/// An operation's own answer, except for one blended as settings
/// ([`Operation::blends_settings`]): that one is active by what it *holds* —
/// a film is active when a stock is loaded — and a layer never holds a stock,
/// only offsets to the photograph's. So a layer's film counts as moved when
/// its sliders are, which is the question being asked.
fn moves(op: &dyn Operation) -> bool {
op.is_active()
|| (op.blends_settings()
&& op
.descriptor()
.params
.iter()
.any(|p| op.param(p.id) != p.default))
}
/// A layer's settings for one operation, applied as offsets to the global
/// operation's.
///
/// **This is what a local adjustment means**, and the reason it is not a
/// second chain run over the finished picture. A photographer who sets
/// contrast −30 on the whole frame and −20 on a face means −50 on the face,
/// at the place contrast sits in the chain — not −30, then everything after
/// contrast, then −20 applied again to the result. Stacked that way the two
/// edits compound in ways neither slider shows, and a flattening applied to an
/// already-flattened picture is how a shadow's noise ended up magenta.
///
/// Per parameter: one the layer left at its default takes the global value; a
/// moved scalar adds its distance from default to the global value, clamped to
/// the parameter's range; a moved switch or choice replaces it, since there is
/// no such thing as half a variant.
fn offset_onto(dst: &mut dyn Operation, local: &dyn Operation, global: Option<&dyn Operation>) {
let desc = local.descriptor();
for p in &desc.params {
let here = local.param(p.id);
let base = global.map_or(p.default, |g| g.param(p.id));
let value = if here == p.default {
base
} else {
match p.kind {
ParamKind::Scalar { .. } => p.clamp(base + (here - p.default)),
ParamKind::Bool | ParamKind::Enum { .. } => here,
}
};
dst.set_param(p.id, value);
}
}
/// Emit the WGSL for every layer that renders, and for the mask being looked /// Emit the WGSL for every layer that renders, and for the mask being looked
/// at. /// at.
/// ///
@@ -2068,11 +2151,18 @@ pub(crate) struct LayerShader {
/// an adjustment on it, which is why the two are one sequence and why every /// an adjustment on it, which is why the two are one sequence and why every
/// other half of the pipeline has to be given the same `reveal` for the slots /// other half of the pipeline has to be given the same `reveal` for the slots
/// to mean the same thing. /// to mean the same thing.
pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal>) -> LayerShader { ///
/// `global` is the chain the layers are offsets to.
pub(crate) fn compose_layers_revealing(
stack: &MaskStack,
reveal: Option<&Reveal>,
global: &[Box<dyn Operation>],
) -> LayerShader {
let mut out = LayerShader { let mut out = LayerShader {
uniform_fields: String::new(), uniform_fields: String::new(),
uniform_values: Vec::new(), uniform_values: Vec::new(),
body: String::new(), weights: String::new(),
ops: Vec::new(),
helpers: Vec::new(), helpers: Vec::new(),
reveal: String::new(), reveal: String::new(),
}; };
@@ -2104,13 +2194,14 @@ pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal
); );
out.uniform_values.extend_from_slice(&layer.uniforms()); out.uniform_values.extend_from_slice(&layer.uniforms());
let _ = writeln!( // A layer only being looked at moves no pixel, so it needs a slot for
out.body, // the reveal and no weight.
"\n // ======== mask {slot}: {} ({}) ========", if layer.active_ops().next().is_none() {
layer.display_name(), continue;
layer.base().source.kind() }
);
let _ = writeln!(out.body, " {{"); // The weight, once per pixel, ahead of every operation that reads it.
//
// **`uv_src`, not `gid.xy`.** The mask array is rasterised in *source* // **`uv_src`, not `gid.xy`.** The mask array is rasterised in *source*
// space, and `uv_src` is the source position this output pixel came // space, and `uv_src` is the source position this output pixel came
// from — after the crop, the zoom, the pan, the straightening and the // from — after the crop, the zoom, the pan, the straightening and the
@@ -2123,33 +2214,62 @@ pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal
// place. A second copy here would be a second thing to keep in step // place. A second copy here would be a second thing to keep in step
// with `Framing::wgsl_prologue`, and the failure would be a mask that // with `Framing::wgsl_prologue`, and the failure would be a mask that
// is subtly wrong only when straightened. // is subtly wrong only when straightened.
let _ = writeln!(out.body, " var m = sample_mask(uv_src, {slot});"); let w = format!("mask_w{slot}");
let _ = writeln!( let _ = writeln!(
out.body, out.weights,
" m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);" "\n // ======== mask {slot}: {} ({}) ========",
layer.display_name(),
layer.base().source.kind()
);
let _ = writeln!(out.weights, " var {w} = sample_mask(uv_src, {slot});");
let _ = writeln!(
out.weights,
" {w} = select({w}, 1.0 - {w}, u.{prefix}_invert > 0.5);"
); );
let _ = writeln!( let _ = writeln!(
out.body, out.weights,
" m = clamp(m * u.{prefix}_opacity, 0.0, 1.0);" " {w} = clamp({w} * u.{prefix}_opacity, 0.0, 1.0);"
); );
// Skipping the work where the mask is empty is most of the point of a
// local adjustment: a mask covering a tenth of the frame should cost
// about a tenth of the shader. Safe as non-uniform control flow —
// nothing inside samples with derivatives or synchronises.
let _ = writeln!(out.body, " if (m > 0.0) {{");
// `masked` is the outer-scope carrier: op fragments write to a `c`
// they expect to own, so the inner block shadows `c` and copies the
// result back out. Assigning the outer `c` from inside is not possible
// precisely because it is shadowed.
let _ = writeln!(out.body, " var masked = c;");
let _ = writeln!(out.body, " {{");
let _ = writeln!(out.body, " var c = masked;");
for op in layer.active_ops() { // A fresh chain to hold the combined settings: the layer's own ops
let id = op.descriptor().id.0; // are its offsets and must stay that way.
let mut combined = layer_chain();
for (dst, local) in combined.iter_mut().zip(&layer.ops) {
let local = local.as_ref();
if !moves(local) || local.detail().is_some() {
continue;
}
let id = local.descriptor().id.0;
let g = global
.iter()
.map(|o| o.as_ref())
.find(|o| o.descriptor().id.0 == id);
// The photograph's stock, lent to the layer's copy: a layer holds
// offsets to a film, never one of its own.
if let Some(g) = g {
dst.set_film_tables(g.film_tables());
}
offset_onto(dst.as_mut(), local, g);
if dst.blends_settings() {
// Its uniforms, and no fragment: the composer blends this
// layer's settings with the others' and runs the global
// fragment once. With no stock loaded there is nothing to
// blend, and the global side skips the operation too.
if !dst.is_active() {
continue;
}
} else if !dst.is_active() {
out.ops.push(LocalOp {
op: id,
slot,
fragment: String::new(),
});
continue;
}
let op_prefix = format!("{prefix}_{}", crate::operation::sanitise(id)); let op_prefix = format!("{prefix}_{}", crate::operation::sanitise(id));
let op_uniforms = dst.uniforms();
let op_uniforms = op.uniforms();
if !op_uniforms.is_empty() { if !op_uniforms.is_empty() {
let _ = writeln!(out.uniform_fields, " // mask {slot}: {id}"); let _ = writeln!(out.uniform_fields, " // mask {slot}: {id}");
} }
@@ -2158,34 +2278,29 @@ pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal
out.uniform_values.push(u.value); out.uniform_values.push(u.value);
} }
for h in op.helpers() { for h in dst.helpers() {
if !out.helpers.iter().any(|e| e.name == h.name) { if !out.helpers.iter().any(|e| e.name == h.name) {
out.helpers.push(*h); out.helpers.push(*h);
} }
} }
let mut fragment = op.wgsl_body(); let mut fragment = String::new();
for u in &op_uniforms { if !dst.blends_settings() {
fragment = crate::operation::rewrite_uniform( fragment = dst.wgsl_body();
&fragment, for u in &op_uniforms {
u.name, fragment = crate::operation::rewrite_uniform(
&format!("u.{op_prefix}_{}", u.name), &fragment,
); u.name,
&format!("u.{op_prefix}_{}", u.name),
);
}
} }
out.ops.push(LocalOp {
let _ = writeln!(out.body, " // ---- {id} ----"); op: id,
let _ = writeln!(out.body, " {{"); slot,
for line in fragment.lines() { fragment,
let _ = writeln!(out.body, " {line}"); });
}
let _ = writeln!(out.body, " }}");
} }
let _ = writeln!(out.body, " masked = c;");
let _ = writeln!(out.body, " }}");
let _ = writeln!(out.body, " c = mix(c, masked, m);");
let _ = writeln!(out.body, " }}");
let _ = writeln!(out.body, " }}");
} }
out out
@@ -2309,6 +2424,78 @@ mod tests {
layer layer
} }
/// A stock the shader can index, with values that are not a real one's.
fn film_fixture() -> crate::graph::Film {
use crate::ops::film_sim::{CURVE_SAMPLES, FORMAT_COUNT};
crate::graph::Film {
stock: "fixture".into(),
print: None,
tables: crate::ops::FilmTables {
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
curves: vec![[0.5; 3]; CURVE_SAMPLES],
push_stations: vec![0.0],
curve_log_min: -3.0,
curve_log_max: 1.0,
lut: vec![[0.5; 3]; 8],
density_max: 2.0,
lut_size: 2,
paper: None,
grain_particles: [[0.0; 3]; FORMAT_COUNT],
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
}
}
#[test]
fn a_layers_film_is_blended_as_settings_and_developed_once() {
// TRACES: FR-DEV-3f
// The film is a rendering: a layer's version of it run beside the
// global one and cross-faded would be the photograph developed twice.
// So the layer's uniforms are averaged into the global ones by weight
// and the fragment appears once.
use crate::ops::film_sim;
let mut graph = crate::EditGraph::default_chain();
graph.set_film(Some(film_fixture()));
let mut layer = MaskLayer::new("m1", regions(&[1]));
layer.set_param(film_sim::ID.0, film_sim::PRINT_EXPOSURE, 1.0);
assert!(layer.is_active(), "a film offset is an adjustment");
graph.masks_mut().push(layer);
let source = graph.compose().source;
assert!(source.contains("let set_w = mask_w0;"), "{source}");
assert!(source.contains("let set_g = max(1.0 - set_w, 0.0);"));
assert!(
source.contains("u.mask0_film_sim_pev"),
"the layer's setting is not read"
);
assert_eq!(
source.matches("let density = film_curve_pushed(").count(),
1,
"the film was developed more than once"
);
assert!(
!source.contains("let local_in"),
"the film was blended as a result"
);
}
#[test]
fn a_layers_film_without_a_stock_composes_to_nothing() {
// TRACES: FR-DEV-3f
// The offsets are kept — a stock chosen later brings them back — but
// with no film on the photograph there is nothing for them to offset.
use crate::ops::film_sim;
let mut graph = crate::EditGraph::default_chain();
let mut layer = MaskLayer::new("m1", regions(&[1]));
layer.set_param(film_sim::ID.0, film_sim::PUSH, 1.0);
graph.masks_mut().push(layer);
let source = graph.compose().source;
assert!(!source.contains("---- film_sim ----"), "{source}");
assert!(!source.contains("mask0_film_sim"));
}
#[test] #[test]
fn a_layer_with_no_adjustment_is_not_in_the_shader() { fn a_layer_with_no_adjustment_is_not_in_the_shader() {
let layer = MaskLayer::new("m1", regions(&[1])); let layer = MaskLayer::new("m1", regions(&[1]));
@@ -2317,7 +2504,10 @@ mod tests {
let mut stack = MaskStack::new(); let mut stack = MaskStack::new();
stack.push(layer); stack.push(layer);
assert!(stack.is_neutral()); assert!(stack.is_neutral());
assert_eq!(compose_layers_revealing(&stack, None).body, ""); assert_eq!(
compose_layers_revealing(&stack, None, &ops::chain()).weights,
""
);
} }
#[test] #[test]
@@ -2403,11 +2593,11 @@ mod tests {
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
stack.push(lit_layer("m2", -1.0)); stack.push(lit_layer("m2", -1.0));
let shader = compose_layers_revealing(&stack, None); let shader = compose_layers_revealing(&stack, None, &ops::chain());
assert!(shader.body.contains("sample_mask(uv_src, 0)")); assert!(shader.weights.contains("sample_mask(uv_src, 0)"));
assert!(shader.body.contains("sample_mask(uv_src, 1)")); assert!(shader.weights.contains("sample_mask(uv_src, 1)"));
assert!(shader.body.contains("u.mask0_opacity")); assert!(shader.weights.contains("u.mask0_opacity"));
assert!(shader.body.contains("u.mask1_opacity")); assert!(shader.weights.contains("u.mask1_opacity"));
} }
/// The slot a layer renders through must follow `active()`, not the raw /// The slot a layer renders through must follow `active()`, not the raw
@@ -2420,12 +2610,12 @@ mod tests {
stack.push(off); stack.push(off);
stack.push(lit_layer("m2", -1.0)); stack.push(lit_layer("m2", -1.0));
let shader = compose_layers_revealing(&stack, None); let shader = compose_layers_revealing(&stack, None, &ops::chain());
assert!( assert!(
shader.body.contains("sample_mask(uv_src, 0)"), shader.weights.contains("sample_mask(uv_src, 0)"),
"the one active layer must use slot 0, not slot 1" "the one active layer must use slot 0, not slot 1"
); );
assert!(!shader.body.contains("sample_mask(uv_src, 1)")); assert!(!shader.weights.contains("sample_mask(uv_src, 1)"));
} }
/// TRACES: FR-DEV-19c /// TRACES: FR-DEV-19c
@@ -2445,7 +2635,7 @@ mod tests {
stack.push(MaskLayer::new("m2", MaskSource::brush())); stack.push(MaskLayer::new("m2", MaskSource::brush()));
let reveal = Reveal::one("m2", RevealStyle::Alpha); let reveal = Reveal::one("m2", RevealStyle::Alpha);
let shader = compose_layers_revealing(&stack, Some(&reveal)); let shader = compose_layers_revealing(&stack, Some(&reveal), &ops::chain());
assert_eq!( assert_eq!(
stack.rendered_count(Some(&reveal)), stack.rendered_count(Some(&reveal)),
@@ -2483,7 +2673,7 @@ mod tests {
], ],
style: RevealStyle::Tint, style: RevealStyle::Tint,
}; };
let shader = compose_layers_revealing(&stack, Some(&reveal)); let shader = compose_layers_revealing(&stack, Some(&reveal), &ops::chain());
let sky = shader let sky = shader
.reveal .reveal
@@ -2509,7 +2699,9 @@ mod tests {
fn nothing_is_revealed_unless_it_was_asked_for() { fn nothing_is_revealed_unless_it_was_asked_for() {
let mut stack = MaskStack::new(); let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
assert!(compose_layers_revealing(&stack, None).reveal.is_empty()); assert!(compose_layers_revealing(&stack, None, &ops::chain())
.reveal
.is_empty());
} }
/// A reveal aimed at a layer that is not in the stack is not a slot, and /// A reveal aimed at a layer that is not in the stack is not a slot, and
@@ -2520,9 +2712,11 @@ mod tests {
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
let reveal = Reveal::one("gone", RevealStyle::Tint); let reveal = Reveal::one("gone", RevealStyle::Tint);
assert_eq!(stack.rendered_count(Some(&reveal)), 1); assert_eq!(stack.rendered_count(Some(&reveal)), 1);
assert!(compose_layers_revealing(&stack, Some(&reveal)) assert!(
.reveal compose_layers_revealing(&stack, Some(&reveal), &ops::chain())
.is_empty()); .reveal
.is_empty()
);
} }
#[test] #[test]
@@ -2531,7 +2725,7 @@ mod tests {
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
stack.push(lit_layer("m2", -1.0)); stack.push(lit_layer("m2", -1.0));
let shader = compose_layers_revealing(&stack, None); let shader = compose_layers_revealing(&stack, None, &ops::chain());
assert!(shader.uniform_fields.contains("mask0_exposure_")); assert!(shader.uniform_fields.contains("mask0_exposure_"));
assert!(shader.uniform_fields.contains("mask1_exposure_")); assert!(shader.uniform_fields.contains("mask1_exposure_"));
assert_eq!( assert_eq!(
@@ -2545,16 +2739,140 @@ mod tests {
); );
} }
/// The whole shader for `stack` over a global chain with `global`
/// applied to it.
fn composed_with(stack: &MaskStack, global: impl FnOnce(&mut [Box<dyn Operation>])) -> String {
let mut chain = ops::chain();
global(&mut chain);
crate::operation::compose_full(
&chain,
&crate::Framing::new(),
dr_types::ColourSpace::Srgb,
stack,
&crate::spot::SpotSet::new(),
&[],
)
.source
}
fn set(chain: &mut [Box<dyn Operation>], op: &str, param: &'static str, v: f32) {
chain
.iter_mut()
.find(|o| o.descriptor().id.0 == op)
.expect("op in chain")
.set_param(ParamId(param), v);
}
fn contrast_layer(v: f32) -> MaskLayer {
let mut layer = MaskLayer::new("m1", regions(&[1]));
layer.set_param("contrast", ParamId("contrast"), v);
layer
}
#[test] #[test]
fn the_inner_block_shadows_c_and_copies_back() { fn the_layer_version_shadows_c_and_blends_by_its_difference() {
let mut stack = MaskStack::new(); let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
let body = compose_layers_revealing(&stack, None).body; let src = composed_with(&stack, |_| {});
assert!(body.contains("var masked = c;")); assert!(src.contains("var c = local_in;"));
assert!(body.contains("var c = masked;")); assert!(src.contains("local_sum = local_sum + mask_w0 * (c - local_global);"));
assert!(body.contains("masked = c;")); assert!(src.contains("c = max(local_sum, vec3<f32>(0.0));"));
assert!(body.contains("c = mix(c, masked, m);")); }
/// **The bug this shape exists for.** A layer's contrast is added to the
/// global contrast, not run a second time on top of it.
#[test]
fn a_layer_setting_is_an_offset_to_the_global_one() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(-20.0));
let mut chain = ops::chain();
set(&mut chain, "contrast", "contrast", -30.0);
let shader = compose_layers_revealing(&stack, None, &chain);
let at = shader
.uniform_fields
.lines()
.filter(|l| l.trim_start().starts_with("mask"))
.position(|l| l.contains("mask0_contrast_amount"))
.expect("the layer carries its own contrast");
assert_eq!(
shader.uniform_values[at], -0.5,
"global -30 and local -20 is -50 inside the mask"
);
}
/// Where the operation runs is where the layer's version of it runs —
/// between the global operations either side, not after all of them.
#[test]
fn a_layer_runs_at_its_operations_place_in_the_chain() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(-20.0));
let src = composed_with(&stack, |c| set(c, "saturation", "saturation", 20.0));
let contrast = src.find("// ---- contrast ----").expect("contrast block");
let blend = src.find("mask_w0 * (c - local_global)").expect("blend");
let saturation = src
.find("// ---- saturation ----")
.expect("saturation block");
assert!(contrast < blend && blend < saturation);
}
/// **No setting is applied twice.** The layer's version of an operation
/// starts from the colour the operation was handed, not from the global
/// result, and reads only its own combined setting — so global −30 and
/// local −20 is one contrast of −50 inside the mask, never −30 and then
/// −50 again, and the operation does not run a second time after the
/// chain as it once did.
#[test]
fn a_setting_is_applied_once_not_stacked() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(-20.0));
let src = composed_with(&stack, |c| set(c, "contrast", "contrast", -30.0));
assert_eq!(
src.matches("// ---- contrast ----").count(),
1,
"contrast runs at one place in the chain"
);
let version = &src[src.find("if (mask_w0 > 0.0)").expect("layer version")..];
let version = &version[..version.find("local_sum = local_sum").unwrap()];
assert!(
version.contains("var c = local_in;"),
"starts from the operation's input"
);
assert!(version.contains("u.mask0_contrast_amount"));
assert!(
!version.contains("u.contrast_amount"),
"the global setting is already inside the combined one"
);
}
/// An offset that cancels the global setting is not nothing: inside the
/// mask the operation is back at neutral, so the layer's version is empty
/// and the blend pulls toward the colour the operation was handed.
#[test]
fn an_offset_back_to_neutral_undoes_the_global_setting() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(30.0));
let mut chain = ops::chain();
set(&mut chain, "contrast", "contrast", -30.0);
let shader = compose_layers_revealing(&stack, None, &chain);
let local: Vec<_> = shader.ops.iter().filter(|l| l.op == "contrast").collect();
assert_eq!(local.len(), 1);
assert!(local[0].fragment.is_empty());
}
/// A global chain with nothing moved still hands a layer's operation a
/// place to run: the global side of the blend is simply empty.
#[test]
fn an_operation_only_a_layer_moved_still_runs_in_its_place() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(-20.0));
let src = composed_with(&stack, |_| {});
assert!(src.contains("// ---- contrast ----"));
assert!(!src.contains("(local only)"));
} }
#[test] #[test]
+2 -2
View File
@@ -82,8 +82,8 @@ const FLOOR: f32 = 1e-4;
/// Move the graph so that `sample` renders neutral. /// Move the graph so that `sample` renders neutral.
/// ///
/// `sample` is the linear triple the operation's own gains multiply — camera /// `sample` is the linear triple the operation's own gains multiply — camera
/// RGB with the camera's as-shot balance on, *before* the body's base curve /// RGB with the camera's as-shot balance on, *before* the camera matrix and
/// and matrix, and with the sampling operation at its defaults. Not the /// the view transform, and with the sampling operation at its defaults. Not the
/// pixel on the screen: the matrix mixes the channels on the way there, so /// pixel on the screen: the matrix mixes the channels on the way there, so
/// a colour read after it does not answer to these gains, and a solve over /// a colour read after it does not answer to these gains, and a solve over
/// one lands somewhere no sample asked for. Returns whether the graph was /// one lands somewhere no sample asked for. Returns whether the graph was
File diff suppressed because it is too large Load Diff
+18 -58
View File
@@ -347,8 +347,15 @@ impl Operation for CaptureSharpen {
impl DetailStage for CaptureSharpen { impl DetailStage for CaptureSharpen {
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> { fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
// A radius finer than one pixel of this render: the detail it would
// act on is not in this texture — it was lost to the downscale before
// this stage ran (FR-DSP-1). Guessing at it would put sharpening on
// screen that the exported file will not contain, so there is no
// pass, and the interface is free to say `zoom to 1:1`. An empty
// chain is a whole render since D19: the fused pass's view pass
// performs the output transform whatever the chain holds.
if !self.resolves(scale) { if !self.resolves(scale) {
return vec![nothing_to_sharpen()]; return Vec::new();
} }
let extent = self.kernel(scale); let extent = self.kernel(scale);
@@ -396,41 +403,6 @@ impl DetailStage for CaptureSharpen {
} }
} }
/// The pass emitted when the radius is finer than a render pixel.
///
/// One dispatch that changes nothing, rather than an empty chain, and the
/// difference is not stylistic. [`crate::operation::compose_full`] decides
/// from the *operations* — before any resolution is known — that an active
/// detail operation means the fused pass hands on unclipped linear values
/// instead of encoding its own output. If this returned no passes at all,
/// that decision would still stand and nothing downstream would ever perform
/// the output transform: `dr-gpu` would be handed a linear-working shader
/// with an empty chain and refuse it.
///
/// So the honest "nothing survives at this scale" still has to carry the
/// encode, and one pass that does only that is exactly the resolve step the
/// stage would otherwise need. It costs a single copy of a proxy-sized
/// texture, which is a rounding error against the dispatches around it.
fn nothing_to_sharpen() -> DetailPass {
DetailPass {
output_scale: 1,
label: "unresolved",
// Reads only the pixel it writes, so a tile needs no halo at all.
radius: 0,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: Vec::new(),
wgsl: "// The chosen radius is finer than one pixel of this render, so the detail
// it would act on is not in this texture — it was lost to the downscale
// before this stage ran (FR-DSP-1). Guessing at it would put sharpening on
// screen that the exported file will not contain, so this pass passes the
// colour through unchanged and the interface is free to say `zoom to 1:1`.
//
// `c` already holds this pixel; leaving it alone is the whole body."
.to_string(),
}
}
/// One axis of the separable unsharp mask. /// One axis of the separable unsharp mask.
/// ///
/// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the /// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the
@@ -528,7 +500,6 @@ c = select(c, scaled, centre > 1e-5);"#;
mod tests { mod tests {
use super::*; use super::*;
use crate::EditGraph; use crate::EditGraph;
use dr_types::ColourSpace;
/// The develop chain with the sharpener turned up. /// The develop chain with the sharpener turned up.
/// ///
@@ -549,7 +520,7 @@ mod tests {
// The scale is what these tests vary, so it is rebuilt into the two // The scale is what these tests vary, so it is rebuilt into the two
// sizes it stands for rather than handed over: a render of the full // sizes it stands for rather than handed over: a render of the full
// frame at `render_size`, from a source of `full_size`. // frame at `render_size`, from a source of `full_size`.
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb) graph.compose_detail(scale.full_size(), scale.render_size())
} }
#[test] #[test]
@@ -591,13 +562,11 @@ mod tests {
assert_eq!(first.label, "capture_sharpen/horizontal"); assert_eq!(first.label, "capture_sharpen/horizontal");
assert_eq!(last.label, "capture_sharpen/vertical"); assert_eq!(last.label, "capture_sharpen/vertical");
assert!(!first.writes_output); // Neither encodes: the view pass after the detail stage does (D19).
assert!(first.source.contains("texture_storage_2d<rgba16float")); for pass in [first, last] {
assert!(!first.source.contains("fn encode_output")); assert!(pass.source.contains("texture_storage_2d<rgba16float"));
assert!(!pass.source.contains("fn encode_output"));
assert!(last.writes_output); }
assert!(last.source.contains("texture_storage_2d<rgba8unorm"));
assert!(last.source.contains("fn encode_output"));
// Two shaders, so two pipeline-cache entries. Sharing one would run // Two shaders, so two pipeline-cache entries. Sharing one would run
// the horizontal pass's uniforms through the vertical pass's slots. // the horizontal pass's uniforms through the vertical pass's slots.
@@ -723,20 +692,11 @@ mod tests {
let proxy = RenderScale::new((1000, 1000), (4000, 4000)); let proxy = RenderScale::new((1000, 1000), (4000, 4000));
assert!(!proxy.resolves(1.0)); assert!(!proxy.resolves(1.0));
// Empty: since D19 nothing in the detail stage encodes, so a chain
// with nothing to draw is a whole render — the fused pass's view pass
// finishes it.
let composed = chain_at(&graph, proxy); let composed = chain_at(&graph, proxy);
// Not empty, though. See `nothing_to_sharpen`: the fused pass has assert!(composed.is_empty());
// already been composed to hand on linear values, so *something* must
// still perform the output transform.
assert_eq!(composed.len(), 1);
assert_eq!(composed.radius(), 0, "it reads no neighbours");
let pass = &composed.passes[0];
assert_eq!(pass.label, "capture_sharpen/unresolved");
assert!(pass.writes_output);
assert!(pass.source.contains("fn encode_output"));
assert!(
!pass.source.contains("for (var i ="),
"the pass-through must not walk a kernel it has decided not to run"
);
// Zooming to 1:1 is what brings it back — the view rect shrinks while // Zooming to 1:1 is what brings it back — the view rect shrinks while
// the render target keeps its size — so there is no separate // the render target keeps its size — so there is no separate
+56 -19
View File
@@ -488,6 +488,36 @@ fn curve_eval(
}", }",
}; };
/// TRACES: FR-DEV-2
/// The curve continued past its last point, for scene values above the
/// widget's axis.
const CURVE_EXTEND: Helper = Helper {
name: "curve_extend",
source: "\
// A five-point curve at `x`, continued past its last point along the slope of
// its last span.
//
// The widget draws a 0..1 axis, and scene-referred values do not stop at 1
// (D19): exposure and highlight recovery put them above it, and the view
// transform after every operation is what brings them down. Flat past the last
// point — which is what `curve_eval` gives, and what this curve did until
// D19 — made every one of them the same number, a hard clip in the middle of
// the chain. Continued along the last span instead, an identity curve stays
// the identity to any height, and a curve that lifts the highlights keeps
// lifting them. The slope is the last span's secant, which is also the
// tangent `curve_eval` gives the last point, so the join is smooth; monotone
// points make it non-negative.
fn curve_extend(
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
x3: f32, y3: f32, x4: f32, y4: f32, x: f32,
) -> f32 {
if (x <= x4) {
return curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, x);
}
return y4 + (x - x4) * max((y4 - y3) / (x4 - x3), 0.0);
}",
};
/// One colour component through its own curve. /// One colour component through its own curve.
const CHANNEL_CURVE: Helper = Helper { const CHANNEL_CURVE: Helper = Helper {
name: "channel_curve", name: "channel_curve",
@@ -504,20 +534,21 @@ const CHANNEL_CURVE: Helper = Helper {
// it changes the proportions between the components, which is what makes it // it changes the proportions between the components, which is what makes it
// chromatic where the master is tonal. // chromatic where the master is tonal.
// //
// The clamp is the curve's promise rather than an oversight: its last point // Above the axis the curve continues along its last span (`curve_extend`),
// *is* white, so a component arriving above the axis takes the value the curve // exactly as the master's does, so a highlight is tinted the way every tone
// gives at 1. The master does the same to a luminance above 1, through the // just below it is — a component that stopped at the curve's top instead
// gain it applies; a channel curve that instead let highlights past unchanged // would put a coloured fringe along a blown edge, and flattened every
// would tint them differently from every tone below them, which reads as a // scene-referred highlight into one value besides (D19). Below zero there is
// coloured fringe along a blown edge. // no light to curve; the floor is the one clamp left, and it is at zero, not
// at one.
fn channel_curve( fn channel_curve(
v: f32, v: f32,
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
x3: f32, y3: f32, x4: f32, y4: f32, x3: f32, y3: f32, x4: f32, y4: f32,
) -> f32 { ) -> f32 {
let encoded = pow(clamp(v, 0.0, 1.0), 1.0 / 2.2); let encoded = pow(max(v, 0.0), 1.0 / 2.2);
let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded); let curved = curve_extend(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
return pow(clamp(curved, 0.0, 1.0), 2.2); return pow(max(curved, 0.0), 2.2);
}", }",
}; };
@@ -535,10 +566,11 @@ static MASTER_HELPERS: &[Helper] = &[
helpers::APPLY_TONE_GAIN, helpers::APPLY_TONE_GAIN,
CURVE_SPAN, CURVE_SPAN,
CURVE_EVAL, CURVE_EVAL,
CURVE_EXTEND,
]; ];
/// The per-channel curves alone. /// The per-channel curves alone.
static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CHANNEL_CURVE]; static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CURVE_EXTEND, CHANNEL_CURVE];
/// Both. /// Both.
static ALL_HELPERS: &[Helper] = &[ static ALL_HELPERS: &[Helper] = &[
@@ -546,6 +578,7 @@ static ALL_HELPERS: &[Helper] = &[
helpers::APPLY_TONE_GAIN, helpers::APPLY_TONE_GAIN,
CURVE_SPAN, CURVE_SPAN,
CURVE_EVAL, CURVE_EVAL,
CURVE_EXTEND,
CHANNEL_CURVE, CHANNEL_CURVE,
]; ];
@@ -553,16 +586,17 @@ static ALL_HELPERS: &[Helper] = &[
const MASTER_BODY: &str = "\ const MASTER_BODY: &str = "\
let luma = luminance(c); let luma = luminance(c);
if (luma > 0.0001) { if (luma > 0.0001) {
// The curve is authored on a display-referred 0..1 axis, which is where // The curve is authored on a 0..1 axis, which is where the widget's grid
// the eye reads tone and where the widget's grid lives. Scene-referred // lives, with a 2.2 gamma so that a point placed at the middle of the
// luminance is unbounded, so it is encoded to that axis, curved, and // grid means the middle of the visible range. Scene-referred luminance
// decoded back — otherwise a point placed at the middle of the grid // does not stop at 1: above the axis the curve continues along its last
// would not correspond to the middle of the visible range. // span (`curve_extend`) rather than clipping, because the view transform
let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2); // after every operation is what brings a highlight down (D19).
let encoded = pow(luma, 1.0 / 2.2);
let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded); let curved = curve_extend(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
let decoded = pow(clamp(curved, 0.0, 1.0), 2.2); let decoded = pow(max(curved, 0.0), 2.2);
// Applied as a ratio so hue is preserved, exactly as contrast does. // Applied as a ratio so hue is preserved, exactly as contrast does.
c = apply_tone_gain(c, decoded / luma); c = apply_tone_gain(c, decoded / luma);
}"; }";
@@ -1288,7 +1322,10 @@ mod tests {
c.set_param(P2_Y, 0.7); c.set_param(P2_Y, 0.7);
let body = c.wgsl_body(); let body = c.wgsl_body();
assert!(body.contains("curve_eval("), "the master curve is missing"); assert!(
body.contains("curve_extend("),
"the master curve is missing"
);
assert!( assert!(
!body.contains("channel_curve("), !body.contains("channel_curve("),
"an untouched channel reached the shader:\n{body}" "an untouched channel reached the shader:\n{body}"
+116 -148
View File
@@ -116,12 +116,34 @@
//! mottling across smooth gradients. This decomposition samples nothing — it //! mottling across smooth gradients. This decomposition samples nothing — it
//! evaluates the exact minimum over every pixel of the window, in two steps. //! evaluates the exact minimum over every pixel of the window, in two steps.
//! //!
//! # Why it is now two passes and not five
//!
//! The decomposition was run as four erosion passes — run and span along x,
//! then along y — and a fifth for the recovery. On the reference laptop the
//! taps turned out not to be what a pass costs: with the memory clock held at
//! 810 MHz by the power cap, a pass that only reads the render-sized
//! `rgba16float` intermediate and writes the other one costs about 4 ms at
//! 2560 x 1600, and dehaze's five came to 22 ms, of which the taps were about
//! 2. So each axis is now one pass that takes the minimum over the whole
//! window directly — 36 texture reads per pixel at that size instead of 12,
//! nearly all of them served by the cache — and the recovery rides in the y
//! pass, which already has the veil and this pixel's colour in hand. Two
//! passes: 9.2 ms.
//!
//! It is the same picture, bit for bit. A minimum is exact in any order, so
//! the minimum over the window's pixels is one value however it is grouped,
//! and the window is the one [`Split`] always covered, surplus pixel
//! included. The veil reaching the recovery was always exactly representable
//! in the `rgba16float` lane it crossed — a minimum of channel values that
//! were themselves read from `rgba16float` — so no rounding was lost by not
//! storing it between passes.
//!
//! It is also why this operation does not use the reduced chain that TD-4 gave //! It is also why this operation does not use the reduced chain that TD-4 gave
//! clarity. The runner holds one reduced buffer, so every scaled pass in a //! clarity. The runner holds one reduced buffer, so every scaled pass in a
//! chain must declare the same `output_scale`; clarity's steps down with the //! chain must declare the same `output_scale`; clarity's steps down with the
//! viewport, so a second operation choosing its own would disagree with it at //! viewport, so a second operation choosing its own would disagree with it at
//! some window sizes and not others. Four cheap full-resolution passes cost //! some window sizes and not others. Two full-resolution passes cost less than
//! less than that coupling, and the decomposition is what makes them cheap. //! that coupling.
//! //!
//! # The artefact this does not fix //! # The artefact this does not fix
//! //!
@@ -275,22 +297,23 @@ impl Dehaze {
/// ///
/// See the module documentation: eroding by a contiguous run and then by a set /// See the module documentation: eroding by a contiguous run and then by a set
/// of points spaced one run apart erodes by the sum of the two, which is the /// of points spaced one run apart erodes by the sum of the two, which is the
/// whole window. This is the arithmetic of that split, in one place, because /// whole window. The passes no longer run the two stages apart (see "Why it is
/// both axes need it and a second copy is a second chance to get the centring /// now two passes"), but the window they read is still the one this split
/// wrong. /// covers — [`Self::first`] and [`Self::width`] — surplus pixel included,
/// because that is the window every edit made so far was tuned against.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct Split { pub struct Split {
/// Length of the contiguous run the first pass takes the minimum over. /// Length of the contiguous run the first pass takes the minimum over.
pub run: u32, pub run: u32,
/// How many runs the second pass chains together, spaced `run` apart. /// How many runs the second pass chains together, spaced `run` apart.
pub span: u32, pub span: u32,
/// What the second pass subtracts from its offsets to centre the window. /// How far before the pixel being written the window starts.
/// ///
/// The composite covers `run * span` pixels, which is at least the window /// The composite covers `run * span` pixels, which is at least the window
/// asked for and can be one or two more; the surplus falls on the far side /// asked for and can be one or two more; the surplus falls on the far side
/// rather than being trimmed, because trimming it would need a third pass /// rather than being trimmed, because trimming it would have needed a
/// and a patch a pixel wider on one side is not a visible difference in a /// third pass and a patch a pixel wider on one side is not a visible
/// field this smooth. /// difference in a field this smooth.
pub shift: i32, pub shift: i32,
} }
@@ -311,14 +334,25 @@ impl Split {
} }
} }
/// The furthest the second pass reads, in pixels. /// The window's first offset from the pixel being written: `-shift`.
pub fn first(&self) -> i32 {
-self.shift
}
/// How many pixels the window covers, `run * span` — the patch asked for
/// and the one or two surplus pixels on the far side the split leaves.
pub fn width(&self) -> u32 {
self.run * self.span
}
/// The furthest the window reads from the pixel being written, in pixels.
/// ///
/// Stated rather than assumed symmetric: the composite window is centred /// Stated rather than assumed symmetric: the window is centred to within a
/// to within a pixel and not exactly, so the two directions can differ by /// pixel and not exactly, so the two directions can differ by one. An
/// one. An understated radius is a seam at every tile boundary (ARCH /// understated radius is a seam at every tile boundary (ARCH §5.3), which
/// §5.3), which is the kind of artefact that looks like a driver bug. /// is the kind of artefact that looks like a driver bug.
pub fn reach(&self) -> u32 { pub fn extent(&self) -> u32 {
let far = (self.span.saturating_sub(1) * self.run) as i32 - self.shift; let far = self.width() as i32 - 1 - self.shift;
self.shift.max(far).max(0) as u32 self.shift.max(far).max(0) as u32
} }
} }
@@ -367,83 +401,50 @@ impl DetailStage for Dehaze {
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> { fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
let split = Split::of(self.patch(scale)); let split = Split::of(self.patch(scale));
// The run stage's uniforms are the same on both axes, and so are the // Both axes erode over the same window; only the offset expression
// span stage's. Only the offset expression differs, which is what // differs, which is what `erode` takes as an argument — one filter
// `erode_run` and `erode_span` take as an argument — each filter
// written once, so the two axes cannot drift into being different // written once, so the two axes cannot drift into being different
// filters. // filters.
let run = vec![Uniform { let window = vec![
name: "run",
value: split.run as f32,
}];
let span = vec![
Uniform { Uniform {
name: "span", name: "first",
value: split.span as f32, value: split.first() as f32,
}, },
Uniform { Uniform {
name: "stride", name: "width",
value: split.run as f32, value: split.width() as f32,
},
Uniform {
name: "shift",
value: split.shift as f32,
}, },
]; ];
let mut recovery = window.clone();
recovery.extend([
Uniform {
name: "omega",
value: self.omega(),
},
Uniform {
name: "min_transmission",
value: MIN_TRANSMISSION,
},
]);
vec![ vec![
DetailPass { DetailPass {
output_scale: 1, output_scale: 1,
label: "veil-run-x", label: "veil-x",
// The run starts at this pixel and walks forward, so it reads radius: split.extent(),
// `run - 1` beyond itself and nothing behind.
radius: split.run.saturating_sub(1),
storage: Vec::new(), storage: Vec::new(),
uniforms: run.clone(), uniforms: window,
wgsl: erode_run(Axis::X), wgsl: erode(Axis::X),
}, },
DetailPass { DetailPass {
output_scale: 1, output_scale: 1,
label: "veil-span-x", label: "veil-y-clear",
radius: split.reach(), radius: split.extent(),
storage: Vec::new(), storage: Vec::new(),
uniforms: span.clone(), uniforms: recovery,
wgsl: erode_span(Axis::X), // The erosion in a block of its own, so its locals do not
}, // collide with the recovery's.
DetailPass { wgsl: format!("{{\n{}\n}}\n\n{CLEAR}", erode(Axis::Y)),
output_scale: 1,
label: "veil-run-y",
radius: split.run.saturating_sub(1),
storage: Vec::new(),
uniforms: run,
wgsl: erode_run(Axis::Y),
},
DetailPass {
output_scale: 1,
label: "veil-span-y",
radius: split.reach(),
storage: Vec::new(),
uniforms: span,
wgsl: erode_span(Axis::Y),
},
DetailPass {
output_scale: 1,
label: "clear",
// Reads only the pixel it writes: the veil arrived in the
// scratch lane four passes ago.
radius: 0,
storage: Vec::new(),
uniforms: vec![
Uniform {
name: "omega",
value: self.omega(),
},
Uniform {
name: "min_transmission",
value: MIN_TRANSMISSION,
},
],
wgsl: CLEAR.to_string(),
}, },
] ]
} }
@@ -466,33 +467,33 @@ impl Axis {
} }
} }
/// The first stage: the minimum over a contiguous run. /// The minimum over the window along one axis.
/// ///
/// Along x it reads the colour and reduces it to the dark channel; along y the /// Along x it reads the colour and reduces it to the dark channel; along y the
/// dark channel is already in the scratch lane, so it reads that instead. /// dark channel's x minimum is already in the scratch lane, so it reads that
/// Doing the channel minimum again on the second axis would be reducing a /// instead. Doing the channel minimum again on the second axis would be
/// scalar and would quietly discard the x erosion. /// reducing a scalar and would quietly discard the x erosion.
/// ///
/// Neither stage touches `c`. The recovery needs the original colour *and* the /// The x pass does not touch `c`. The recovery needs the original colour *and*
/// veil in the same place at the same time, and the ping-pong hands each pass /// the veil in the same place at the same time, and the ping-pong hands each
/// only what the pass before it wrote — so the veil travels in `aux` and the /// pass only what the pass before it wrote — so the veil travels in `aux` and
/// colour rides through untouched. See [`DetailPass::wgsl`]. /// the colour rides through untouched. See [`DetailPass::wgsl`].
fn erode_run(axis: Axis) -> String { fn erode(axis: Axis) -> String {
let source = match axis { let source = match axis {
Axis::X => "dark_channel(tap(coord, OFFSET))", Axis::X => "dark_channel(tap(coord, OFFSET))",
Axis::Y => "tap_aux(coord, OFFSET)", Axis::Y => "tap_aux(coord, OFFSET)",
}; };
let first = source.replace("OFFSET", &axis.offset("0")); let head = source.replace("OFFSET", &axis.offset("o"));
let rest = source.replace("OFFSET", &axis.offset("i")); let rest = source.replace("OFFSET", &axis.offset("o + i"));
format!( format!(
"\ "\
// Half of the erosion's first stage: the minimum over `run` contiguous pixels, // The minimum over every pixel of the window along this axis, from `first`
// walking forward from this one. The second stage chains these together, and // for `width` pixels. A minimum is exact in any order, so this is the same
// the two structuring elements add up to the whole patch — which is why this // value, bit for bit, as chaining a run and a span over the same pixels.
// one is not centred and does not need to be. let o = i32(first);
let n = i32(run); let n = i32(width);
var veil = {first}; var veil = {head};
for (var i = 1; i < n; i = i + 1) {{ for (var i = 1; i < n; i = i + 1) {{
veil = min(veil, {rest}); veil = min(veil, {rest});
}} }}
@@ -500,38 +501,10 @@ aux = veil;"
) )
} }
/// The second stage: the minimum over `span` points spaced `stride` apart.
///
/// Each of those points already holds the minimum over the run that starts
/// there, so this reads the whole window while touching `span` pixels of it.
/// `shift` is what centres the composite on the pixel being written; without
/// it the veil would be measured from a patch lying entirely to one side, and
/// the correction would appear to lag the picture by half a patch.
fn erode_span(axis: Axis) -> String {
let offset = axis.offset("j * s - o");
let first = axis.offset("-o");
format!(
"\
// The erosion's second stage. `span` taps, spaced a whole run apart, each
// standing for the run that begins at it — so the minimum over the patch costs
// `run + span` taps rather than the `run * span` pixels it covers, and it is
// the exact minimum over all of them rather than a sample of them.
let n = i32(span);
let s = i32(stride);
let o = i32(shift);
var veil = tap_aux(coord, {first});
for (var j = 1; j < n; j = j + 1) {{
veil = min(veil, tap_aux(coord, {offset}));
}}
aux = veil;"
)
}
/// The recovery: invert the scattering model with the transmission the erosion /// The recovery: invert the scattering model with the transmission the erosion
/// implies. /// implies.
const CLEAR: &str = "\ const CLEAR: &str = "\
// The veil the four erosion passes measured: the smallest channel anywhere in // The veil the two erosions measured: the smallest channel anywhere in
// the patch around this pixel, which the dark-channel prior reads as the // the patch around this pixel, which the dark-channel prior reads as the
// airlight that has been composited over the scene here. // airlight that has been composited over the scene here.
// //
@@ -568,20 +541,19 @@ c = (c - lifted) / t;";
mod tests { mod tests {
use super::*; use super::*;
use crate::detail::compose_detail; use crate::detail::compose_detail;
use dr_types::ColourSpace;
fn ops(amount: f32) -> Vec<Box<dyn Operation>> { fn ops(amount: f32) -> Vec<Box<dyn Operation>> {
vec![Box::new(Dehaze::with_amount(amount))] vec![Box::new(Dehaze::with_amount(amount))]
} }
fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail { fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail {
compose_detail(&ops(amount), scale, ColourSpace::Srgb) compose_detail(&ops(amount), scale)
} }
#[test] #[test]
fn dehaze_starts_neutral_and_costs_nothing() { fn dehaze_starts_neutral_and_costs_nothing() {
// The rule the whole pipeline rests on. An unedited photograph must not // The rule the whole pipeline rests on. An unedited photograph must not
// pay for a slider nobody has touched — and this one is five dispatches // pay for a slider nobody has touched — and this one is two dispatches
// when it is on, so "nothing" here is a worthwhile amount of nothing. // when it is on, so "nothing" here is a worthwhile amount of nothing.
assert!(!Dehaze::new().is_active()); assert!(!Dehaze::new().is_active());
assert!(composed(0.0, RenderScale::full((2000, 1500))).is_empty()); assert!(composed(0.0, RenderScale::full((2000, 1500))).is_empty());
@@ -623,7 +595,7 @@ mod tests {
// develop view about it would read as a bug. // develop view about it would read as a bug.
let tiny = RenderScale::full((48, 32)); let tiny = RenderScale::full((48, 32));
assert_eq!(Dehaze::with_amount(50.0).patch(tiny), 1); assert_eq!(Dehaze::with_amount(50.0).patch(tiny), 1);
assert_eq!(composed(50.0, tiny).len(), 5); assert_eq!(composed(50.0, tiny).len(), 2);
} }
#[test] #[test]
@@ -648,7 +620,8 @@ mod tests {
// Centred to within the pixel the odd surplus leaves over: the window // Centred to within the pixel the odd surplus leaves over: the window
// covers [-31, 32] around the pixel being written. // covers [-31, 32] around the pixel being written.
assert_eq!(split.shift, 31); assert_eq!(split.shift, 31);
assert_eq!(split.reach(), 31); assert_eq!((split.first(), split.width()), (-31, 64));
assert_eq!(split.extent(), 32);
} }
#[test] #[test]
@@ -662,33 +635,27 @@ mod tests {
assert_eq!(split.span, 2); assert_eq!(split.span, 2);
assert!(split.run * split.span >= 3, "the window is not covered"); assert!(split.run * split.span >= 3, "the window is not covered");
assert_eq!(split.shift, 1); assert_eq!(split.shift, 1);
assert_eq!(split.reach(), 1); // [-1, 2]: the surplus pixel is on the far side.
assert_eq!((split.first(), split.width()), (-1, 4));
assert_eq!(split.extent(), 2);
} }
#[test] #[test]
fn the_chain_is_four_erosions_and_a_recovery() { fn the_chain_is_an_erosion_per_axis_the_second_carrying_the_recovery() {
// The shape of the operation, asserted where it is cheap to assert. // The shape of the operation, asserted where it is cheap to assert.
// The erosions leave the colour alone and hand the veil forward in the // The x erosion leaves the colour alone and hands the veil forward in
// scratch lane; only the last pass touches `c`, which is what makes an // the scratch lane; only the y pass touches `c`, which is what makes an
// unsharp-mask-shaped operation expressible in a chain that hands each // unsharp-mask-shaped operation expressible in a chain that hands each
// pass exactly one texture. // pass exactly one texture. Two passes and not five: each extra pass
// is a render-sized read and write, which is what a pass costs (see
// "Why it is now two passes").
let composed = composed(60.0, RenderScale::full((2000, 1500))); let composed = composed(60.0, RenderScale::full((2000, 1500)));
let labels: Vec<&str> = composed.passes.iter().map(|p| p.label.as_str()).collect(); let labels: Vec<&str> = composed.passes.iter().map(|p| p.label.as_str()).collect();
assert_eq!( assert_eq!(labels, ["dehaze/veil-x", "dehaze/veil-y-clear"]);
labels,
[
"dehaze/veil-run-x",
"dehaze/veil-span-x",
"dehaze/veil-run-y",
"dehaze/veil-span-y",
"dehaze/clear",
]
);
// Only the last writes the display texture, so the output transform // Both declare the whole window they read.
// happens exactly once (FR-DEV-2). let split = Split::of(Dehaze::with_amount(60.0).patch(RenderScale::full((2000, 1500))));
assert!(composed.passes[..4].iter().all(|p| !p.writes_output)); assert!(composed.passes.iter().all(|p| p.radius == split.extent()));
assert!(composed.passes[4].writes_output);
// Nothing here uses the reduced chain — see the module documentation // Nothing here uses the reduced chain — see the module documentation
// for why a second operation cannot pick its own `output_scale` while // for why a second operation cannot pick its own `output_scale` while
@@ -730,7 +697,8 @@ mod tests {
// nothing to say so. // nothing to say so.
let composed = composed(60.0, RenderScale::full((2000, 1500))); let composed = composed(60.0, RenderScale::full((2000, 1500)));
assert!(composed.passes[0].source.contains("dark_channel(tap(coord")); assert!(composed.passes[0].source.contains("dark_channel(tap(coord"));
assert!(!composed.passes[2].source.contains("dark_channel(tap(coord")); assert!(!composed.passes[1].source.contains("dark_channel(tap(coord"));
assert!(composed.passes[1].source.contains("tap_aux(coord"));
// The helper is still emitted for every pass of the operation, and it // The helper is still emitted for every pass of the operation, and it
// must define the function it is named for or the shader fails to // must define the function it is named for or the shader fails to
// compile a long way from here. // compile a long way from here.
+272 -122
View File
@@ -1,30 +1,35 @@
//! TRACES: FR-DEV-3f //! TRACES: FR-DEV-3f
//! Film simulation — the stock renders the picture. //! Film simulation — the stock renders the picture.
//! //!
//! # Why this one replaces the base curve //! # Why this one is the view transform
//! //!
//! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The base //! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The view
//! curve exists because sensor data is scene-referred and nothing anybody looks //! transform exists because sensor data is scene-referred and nothing anybody
//! at is (FR-DEV-3e); a film stock's characteristic curve does the same job, //! looks at is (FR-DEV-3j); a film stock's characteristic curve does the same
//! from measurements, with a toe and a shoulder that were coated onto acetate //! job, from measurements, with a toe and a shoulder that were coated onto
//! rather than drawn. Running both renders the image twice — the camera's //! acetate rather than drawn. Running both renders the image twice — the
//! JPEG-ish rendering, and then a film's rendering of that — which is not what //! default rendering, and then a film's rendering of that — which is not what
//! either is for and looks like neither. //! either is for and looks like neither.
//! //!
//! So this node declares [`Operation::renders`], and the composer answers by //! So this node is in [`Stage::View`] and declares [`Operation::renders`]: when
//! emitting neither the base curve nor the camera matrix. Both jobs move here: //! a stock is loaded the composer puts it at the end of the chain in place of
//! the fragment takes camera RGB, converts it to linear sRGB itself with the //! the default sigmoid (D19). It is handed working-space colour — linear sRGB
//! matrix already in the uniform block, and returns linear sRGB. That is a //! primaries, scene-referred, after every other operation and after the detail
//! contract worth stating plainly, because a node that got half of it wrong //! stage — and returns display-referred linear sRGB for the output transform.
//! would produce a picture that renders perfectly and is wrong everywhere. //! Before D19 it ran at order 25, after exposure and before everything else,
//! and the operations below it acted on its output. They now act on the scene
//! it is shown: an edit is a decision about the exposure the negative
//! receives, and the film is the last thing that happens to the picture.
//! //!
//! # Why the tables are not parameters //! # Why the tables are not parameters
//! //!
//! For the same reason [`crate::ops::vignetting`]'s coefficients are not: they //! For the same reason [`crate::ops::vignetting`]'s coefficients are not: they
//! are measurements of a physical thing, not something a slider moves. The //! are measurements of a physical thing, not something a slider moves. The
//! sliders here are exposure and print exposure, which are what a photographer //! sliders here are exposure, push, print exposure and format, which are what
//! and a printer actually control. `dr-film` turns a stock plus those two //! a photographer and a printer actually control. `dr-film` turns a stock into
//! numbers into [`FilmTables`]; this node knows only the layout. //! [`FilmTables`] that hold none of them; the shader applies all four per
//! pixel, which is what lets a mask layer hold its own (see
//! [`Operation::blends_settings`]). This node knows only the layout.
//! //!
//! Declared as a plain struct here rather than imported, so that dr-pipeline //! Declared as a plain struct here rather than imported, so that dr-pipeline
//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does //! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does
@@ -32,7 +37,7 @@
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId}; use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Operation, Uniform}; use crate::operation::{Operation, Stage, Uniform};
pub const ID: OpId = OpId("film_sim"); pub const ID: OpId = OpId("film_sim");
pub const EXPOSURE: ParamId = ParamId("exposure"); pub const EXPOSURE: ParamId = ParamId("exposure");
@@ -63,6 +68,15 @@ static FORMATS: [LocalizedKey; 6] = [
/// take; [`FilmTables::is_well_formed`] is what stops the two drifting. /// take; [`FilmTables::is_well_formed`] is what stops the two drifting.
pub const CURVE_SAMPLES: usize = 256; pub const CURVE_SAMPLES: usize = 256;
/// The most development times a stock may measure — a curve row and a push
/// station each. Must agree with `dr_film::bake::MAX_CURVE_ROWS`, for the
/// reason [`CURVE_SAMPLES`] must; the uniform block holds this many stations.
pub const MAX_CURVE_ROWS: usize = 8;
/// How many frames [`FORMATS`] offers, and so how many grain counts a stock
/// carries.
pub const FORMAT_COUNT: usize = 6;
/// The uniform field names the fragment reads the exposure matrix from. /// The uniform field names the fragment reads the exposure matrix from.
/// ///
/// A table rather than a formatted string, because a `Uniform`'s name is /// A table rather than a formatted string, because a `Uniform`'s name is
@@ -74,6 +88,24 @@ static MATRIX_FIELDS: [[&str; 3]; 3] = [
["m20", "m21", "m22"], ["m20", "m21", "m22"],
]; ];
/// Grains per pixel, per format and layer: `gn{format}{layer}`.
static GRAIN_FIELDS: [[&str; 3]; FORMAT_COUNT] = [
["gn00", "gn01", "gn02"],
["gn10", "gn11", "gn12"],
["gn20", "gn21", "gn22"],
["gn30", "gn31", "gn32"],
["gn40", "gn41", "gn42"],
["gn50", "gn51", "gn52"],
];
/// The push each curve row was developed to, padded with the last.
static PUSH_FIELDS: [&str; MAX_CURVE_ROWS] =
["ps0", "ps1", "ps2", "ps3", "ps4", "ps5", "ps6", "ps7"];
/// Which format this is, one-hot. See [`FilmSim::uniforms`] for why a choice
/// reaches the shader as six weights rather than an index.
static FORMAT_FIELDS: [&str; FORMAT_COUNT] = ["fmt0", "fmt1", "fmt2", "fmt3", "fmt4", "fmt5"];
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| { static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor { Arc::new(OpDescriptor {
// Tone and colour both, and not `Effect`: a stock is not something applied // Tone and colour both, and not `Effect`: a stock is not something applied
@@ -115,48 +147,86 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
/// Layout is the contract between the two crates, so it is written down here /// Layout is the contract between the two crates, so it is written down here
/// and checked rather than assumed: /// and checked rather than assumed:
/// ///
/// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel `c`. /// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel
/// - `curves` — `CURVE_SAMPLES` density triples, uniform over /// `c`, at unit gain: camera exposure is a per-pixel setting.
/// `[curve_log_min, curve_log_max]`. /// - `curves` — one row of `CURVE_SAMPLES` density triples per
/// - `lut` — `lut_size³` linear sRGB triples, uniform over `[0, density_max]` /// `push_stations` entry, uniform over `[curve_log_min, curve_log_max]`,
/// on each axis, with the **red axis varying fastest**: index /// and then, when printed, one more row: the paper's, uniform over
/// `(b * size + g) * size + r`. That is the order a 3D texture upload /// `[paper.log_min, paper.log_max]`.
/// expects, so the consumer hands the slice straight to the driver. Filling /// - `lut` — `lut_size³` triples uniform over `[0, density_max]` on each
/// it the other way round transposes red and blue in the finished picture — /// axis, with the **red axis varying fastest**: index
/// which is a plausible photograph of the wrong colour, and which the unit /// `(b * size + g) * size + r`. Linear sRGB when the film is viewed
/// tests on both sides of this seam happily pass, because each side is /// directly; the paper's log₁₀ exposure through the negative when it is
/// internally consistent. `dr-film` pins it; `dr-gpu`'s `film_sim` test /// printed, followed by a second cube, paper density over
/// catches it end to end. /// `[0, paper.density_max]` to linear sRGB. That is the order a 3D texture
/// upload expects with the cubes stacked in depth, so the consumer hands the
/// slice straight to the driver. Filling it the other way round transposes
/// red and blue in the finished picture — which is a plausible photograph
/// of the wrong colour, and which the unit tests on both sides of this seam
/// happily pass, because each side is internally consistent. `dr-film` pins
/// it; `dr-gpu`'s `film_sim` test catches it end to end.
///
/// Everything the sliders move — exposure, push, print exposure, format — is
/// absent. They are per-pixel settings the shader applies against these
/// tables, which is what lets a mask layer hold its own.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct FilmTables { pub struct FilmTables {
pub exposure_matrix: [[f32; 3]; 3], pub exposure_matrix: [[f32; 3]; 3],
pub curves: Vec<[f32; 3]>, pub curves: Vec<[f32; 3]>,
/// The push each film row was developed to, ascending: one entry for a
/// stock measured at a single process.
pub push_stations: Vec<f32>,
pub curve_log_min: f32, pub curve_log_min: f32,
pub curve_log_max: f32, pub curve_log_max: f32,
pub lut: Vec<[f32; 3]>, pub lut: Vec<[f32; 3]>,
pub density_max: f32, pub density_max: f32,
pub lut_size: usize, pub lut_size: usize,
/// The print, for a negative printed on paper.
pub paper: Option<PaperTables>,
/// TRACES: FR-DEV-3f /// TRACES: FR-DEV-3f
/// Grains in one pixel's patch of film, per layer, with the density /// Grains in one pixel's patch of film, per format and then per layer,
/// ceiling and uniformity the variance is taken against. Zero particles /// with the density ceiling and uniformity the variance is taken against.
/// means no grain, which is how the control is turned off. /// Zero particles means no grain, which is how the control is turned off.
pub grain_particles: [f32; 3], pub grain_particles: [[f32; 3]; FORMAT_COUNT],
pub grain_density_max: [f32; 3], pub grain_density_max: [f32; 3],
pub grain_uniformity: f32, pub grain_uniformity: f32,
} }
/// The print half of [`FilmTables`]: where the paper's row and cube are read.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaperTables {
/// The enlarger's filtration, per layer, in log₁₀ exposure.
pub balance: [f32; 3],
pub log_min: f32,
pub log_max: f32,
pub density_max: f32,
}
impl FilmTables { impl FilmTables {
/// Film rows, not counting the paper's.
pub fn curve_rows(&self) -> usize {
self.push_stations.len()
}
/// Whether these tables are the shape the shader will index them at. /// Whether these tables are the shape the shader will index them at.
/// ///
/// Checked on the way in, because the failure otherwise is a shader /// Checked on the way in, because the failure otherwise is a shader
/// sampling past the end of a texture: undefined, silent, and different on /// sampling past the end of a texture: undefined, silent, and different on
/// every driver. /// every driver.
pub fn is_well_formed(&self) -> bool { pub fn is_well_formed(&self) -> bool {
self.curves.len() == CURVE_SAMPLES let rows = self.curve_rows();
let printed = usize::from(self.paper.is_some());
let paper_ok = self
.paper
.is_none_or(|p| p.density_max > 0.0 && p.log_max > p.log_min);
(1..=MAX_CURVE_ROWS).contains(&rows)
&& self.push_stations.windows(2).all(|w| w[0] < w[1])
&& self.curves.len() == CURVE_SAMPLES * (rows + printed)
&& self.lut_size >= 2 && self.lut_size >= 2
&& self.lut.len() == self.lut_size.pow(3) && self.lut.len() == self.lut_size.pow(3) * (1 + printed)
&& self.density_max > 0.0 && self.density_max > 0.0
&& self.curve_log_max > self.curve_log_min && self.curve_log_max > self.curve_log_min
&& paper_ok
} }
} }
@@ -237,69 +307,95 @@ impl Operation for FilmSim {
self.tables.is_some() self.tables.is_some()
} }
/// This node renders; the camera's own rendering must not also run. /// This node renders; the default view transform must not also run.
fn renders(&self) -> bool { fn renders(&self) -> bool {
true true
} }
/// TRACES: FR-DEV-3f | FR-DEV-3j
/// The view transform's place, at the end of the chain (D19).
fn stage(&self) -> Stage {
Stage::View
}
fn set_film_tables(&mut self, tables: Option<&FilmTables>) { fn set_film_tables(&mut self, tables: Option<&FilmTables>) {
self.set_tables(tables.cloned()); self.set_tables(tables.cloned());
} }
fn film_tables(&self) -> Option<&FilmTables> {
self.tables.as_ref()
}
/// TRACES: FR-DEV-3f
/// A layer's film is its settings, not its own picture blended over the
/// global one.
///
/// Blending outputs would be a photograph developed twice and cross-faded;
/// a region on a pushed film is not that. Every uniform below is linear
/// in what it controls, so the composer can take each layer's weighted
/// average of them and develop the pixel once.
fn blends_settings(&self) -> bool {
true
}
/// Every value here is linear in what the shader does with it, which is
/// what [`Self::blends_settings`] rests on. The format is the one that
/// needs arranging: an index averaged between layers is a format nobody
/// chose, so it goes out one-hot and the shader mixes the six grain
/// counts by it — two layers on 35 mm and 6x7 meet at the average grain.
fn uniforms(&self) -> Vec<Uniform> { fn uniforms(&self) -> Vec<Uniform> {
let Some(t) = &self.tables else { let Some(t) = &self.tables else {
return Vec::new(); return Vec::new();
}; };
let m = t.exposure_matrix; let mut out = Vec::with_capacity(64);
// Exposure rides in the matrix on the CPU when the stock is baked, so let mut push = |name: &'static str, value: f32| out.push(Uniform { name, value });
// what is left here is the *shader's* copy of the same nine numbers. for (l, row) in t.exposure_matrix.iter().enumerate() {
// Spelled out one at a time because a uniform is a named `f32` in this
// pipeline and a matrix would be a second kind of thing for one caller.
let mut out = Vec::with_capacity(MATRIX_FIELDS.len() + 5);
for (l, row) in m.iter().enumerate() {
for (c, v) in row.iter().enumerate() { for (c, v) in row.iter().enumerate() {
out.push(Uniform { push(MATRIX_FIELDS[l][c], *v);
name: MATRIX_FIELDS[l][c],
value: *v,
});
} }
} }
for (l, name) in ["gn0", "gn1", "gn2"].into_iter().enumerate() { for (f, per_layer) in t.grain_particles.iter().enumerate() {
out.push(Uniform { for (l, v) in per_layer.iter().enumerate() {
name, push(GRAIN_FIELDS[f][l], *v);
value: t.grain_particles[l], }
});
} }
for (l, name) in ["gd0", "gd1", "gd2"].into_iter().enumerate() { for (l, name) in ["gd0", "gd1", "gd2"].into_iter().enumerate() {
out.push(Uniform { push(name, t.grain_density_max[l]);
name,
value: t.grain_density_max[l],
});
} }
out.push(Uniform { push("grain_u", t.grain_uniformity);
name: "grain_u", push("log_min", t.curve_log_min);
value: t.grain_uniformity, push("log_max", t.curve_log_max);
}); push("density_max", t.density_max);
out.push(Uniform { push("lut_size", t.lut_size as f32);
name: "log_min",
value: t.curve_log_min, let last = *t.push_stations.last().unwrap_or(&0.0);
}); for (i, name) in PUSH_FIELDS.into_iter().enumerate() {
out.push(Uniform { push(name, t.push_stations.get(i).copied().unwrap_or(last));
name: "log_max", }
value: t.curve_log_max, push("rows", t.curve_rows() as f32);
});
out.push(Uniform { let paper = t.paper.unwrap_or(PaperTables {
name: "density_max", balance: [0.0; 3],
value: t.density_max, log_min: 0.0,
}); log_max: 1.0,
out.push(Uniform { density_max: 1.0,
name: "lut_size",
value: t.lut_size as f32,
});
out.push(Uniform {
name: "print_exposure",
value: self.print_exposure,
}); });
push("printed", if t.paper.is_some() { 1.0 } else { 0.0 });
for (l, name) in ["pb0", "pb1", "pb2"].into_iter().enumerate() {
push(name, paper.balance[l]);
}
push("plog_min", paper.log_min);
push("plog_max", paper.log_max);
push("pdmax", paper.density_max);
// The sliders.
push("ev", self.exposure);
push("push", self.push);
push("pev", self.print_exposure);
let chosen = (self.format.max(0.0).round() as usize).min(FORMAT_COUNT - 1);
for (f, name) in FORMAT_FIELDS.into_iter().enumerate() {
push(name, if f == chosen { 1.0 } else { 0.0 });
}
out out
} }
@@ -309,20 +405,17 @@ impl Operation for FilmSim {
// sampler binding, and adding one to interpolate two lookups would // sampler binding, and adding one to interpolate two lookups would
// cost a binding in every shader whether or not a film is loaded. // cost a binding in every shader whether or not a film is loaded.
"\ "\
// Camera RGB to linear sRGB. The film's exposure matrix is defined against // Working-space colour, which is linear sRGB primaries — what the film's
// sRGB primaries, and this node has taken over the conversion the composer // exposure matrix is defined against. The composer converted out of camera
// would otherwise have emitted at the end — see `Operation::renders`. // RGB before any scene-stage operation ran (D19).
let scene = vec3<f32>( let scene = c;
dot(u.cam_to_srgb_0.rgb, c),
dot(u.cam_to_srgb_1.rgb, c),
dot(u.cam_to_srgb_2.rgb, c),
);
// What each emulsion layer was exposed to. A matrix, exactly: the scene // What each emulsion layer was exposed to. A matrix, exactly: the scene
// spectrum reconstructed from an sRGB triple is linear in that triple, so the // spectrum reconstructed from an sRGB triple is linear in that triple, so the
// integral over wavelength collapsed into these nine numbers when the stock // integral over wavelength collapsed into these nine numbers when the stock
// was baked. // was baked. The camera's exposure is a gain on it, applied here rather than
let exposure = vec3<f32>( // baked in so that a layer can hold its own.
let exposure = exp2(ev) * vec3<f32>(
dot(vec3<f32>(m00, m01, m02), scene), dot(vec3<f32>(m00, m01, m02), scene),
dot(vec3<f32>(m10, m11, m12), scene), dot(vec3<f32>(m10, m11, m12), scene),
dot(vec3<f32>(m20, m21, m22), scene), dot(vec3<f32>(m20, m21, m22), scene),
@@ -331,12 +424,16 @@ let exposure = vec3<f32>(
// the curve, and the toe is where it belongs. // the curve, and the toe is where it belongs.
let log_exposure = log10(max(exposure, vec3<f32>(0.0)) + 1e-10); let log_exposure = log10(max(exposure, vec3<f32>(0.0)) + 1e-10);
// The characteristic curve: what density each layer develops to. Clamped, not // The characteristic curve: what density each layer develops to, at this
// extrapolated — past the shoulder a real emulsion stops responding, and // pixel's push. Clamped, not extrapolated — past the shoulder a real emulsion
// extrapolating would turn a blown highlight into a colour cast that grows the // stops responding, and extrapolating would turn a blown highlight into a
// more it is overexposed. // colour cast that grows the more it is overexposed.
let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min), let density = film_curve_pushed(
vec3<f32>(0.0), vec3<f32>(1.0))); clamp((log_exposure - log_min) / (log_max - log_min), vec3<f32>(0.0), vec3<f32>(1.0)),
push,
array<f32, 8>(ps0, ps1, ps2, ps3, ps4, ps5, ps6, ps7),
u32(rows),
);
// TRACES: FR-DEV-3f // TRACES: FR-DEV-3f
// Grain, on the density and before the dye. // Grain, on the density and before the dye.
@@ -346,16 +443,40 @@ let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min),
// through whatever density resulted. Adding noise to the finished colour -- // through whatever density resulted. Adding noise to the finished colour --
// which is what an effect does -- tints the highlights wrong, because that // which is what an effect does -- tints the highlights wrong, because that
// noise never passes through the dye at all. // noise never passes through the dye at all.
let grained = film_grain(density, source_px, //
vec3<f32>(gn0, gn1, gn2), // The format's grain count, mixed by the one-hot weights: exactly one format's
// on the whole photograph, and the weighted average under overlapping layers.
let particles = fmt0 * vec3<f32>(gn00, gn01, gn02)
+ fmt1 * vec3<f32>(gn10, gn11, gn12)
+ fmt2 * vec3<f32>(gn20, gn21, gn22)
+ fmt3 * vec3<f32>(gn30, gn31, gn32)
+ fmt4 * vec3<f32>(gn40, gn41, gn42)
+ fmt5 * vec3<f32>(gn50, gn51, gn52);
let grained = film_grain(density, source_px, particles,
vec3<f32>(gd0, gd1, gd2), vec3<f32>(gd0, gd1, gd2),
grain_u); grain_u);
// Dye absorption, the print through the negative, the paper, the viewing // Dye absorption through to what comes next — all of it takes exactly three
// illuminant and the chromatic adaptation — all of which take exactly three // numbers in, which is why it fits in one lookup. Viewed directly, that is
// numbers in, which is why they fit in one lookup. // the picture; printed, it is the light the paper receives through the
c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_size);" // negative, in log exposure.
.into() let through = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)),
lut_size, 0);
if (printed > 0.5) {
// The enlarger: its filtration, and then its exposure, the same stops on
// every layer — which is why print exposure is an addition here and not
// a table, and so exact at any setting.
let paper_log = through + vec3<f32>(pb0, pb1, pb2) + pev * 0.30103;
let paper_density = film_curve(
clamp((paper_log - plog_min) / (plog_max - plog_min), vec3<f32>(0.0), vec3<f32>(1.0)),
u32(rows),
);
c = film_lut(clamp(paper_density / pdmax, vec3<f32>(0.0), vec3<f32>(1.0)),
lut_size, i32(lut_size));
} else {
c = through;
}"
.into()
} }
fn helpers(&self) -> &'static [crate::operation::Helper] { fn helpers(&self) -> &'static [crate::operation::Helper] {
@@ -363,7 +484,7 @@ c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_s
} }
} }
static HELPERS: [crate::operation::Helper; 5] = [ static HELPERS: [crate::operation::Helper; 6] = [
crate::operation::Helper { crate::operation::Helper {
name: "film_hash", name: "film_hash",
source: "\ source: "\
@@ -453,9 +574,9 @@ fn log10(v: vec3<f32>) -> vec3<f32> {
crate::operation::Helper { crate::operation::Helper {
name: "film_curve", name: "film_curve",
source: "\ source: "\
// Three characteristic curves, sampled from a 256-wide texture and // Three characteristic curves, one row of a 256-wide texture, interpolated by
// interpolated by hand. `t` is already normalised to the curve's domain. // hand. `t` is already normalised to the curve's domain.
fn film_curve(t: vec3<f32>) -> vec3<f32> { fn film_curve(t: vec3<f32>, row: u32) -> vec3<f32> {
let samples = u32(textureDimensions(film_curves).x); let samples = u32(textureDimensions(film_curves).x);
let last = f32(samples - 1u); let last = f32(samples - 1u);
var out = vec3<f32>(0.0); var out = vec3<f32>(0.0);
@@ -463,20 +584,45 @@ fn film_curve(t: vec3<f32>) -> vec3<f32> {
let x = t[ch] * last; let x = t[ch] * last;
let i = min(u32(floor(x)), samples - 2u); let i = min(u32(floor(x)), samples - 2u);
let f = x - f32(i); let f = x - f32(i);
let a = textureLoad(film_curves, vec2<i32>(i32(i), 0), 0); let a = textureLoad(film_curves, vec2<i32>(i32(i), i32(row)), 0);
let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, 0), 0); let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, i32(row)), 0);
out[ch] = mix(a[ch], b[ch], f); out[ch] = mix(a[ch], b[ch], f);
} }
return out; return out;
}",
},
crate::operation::Helper {
name: "film_curve_pushed",
source: "\
// The curves at a push between two measured processes. Development is
// interpolated in log time and push *is* log time, so a straight line between
// the neighbouring rows is the stock's own interpolation, not an estimate of
// it. Clamped to the first and last process, as the stock is.
fn film_curve_pushed(t: vec3<f32>, push: f32, stations: array<f32, 8>, rows: u32) -> vec3<f32> {
if (rows < 2u) {
return film_curve(t, 0u);
}
var at = stations;
var hi = rows - 1u;
for (var i = 1u; i < rows; i = i + 1u) {
if (at[i] >= push) {
hi = i;
break;
}
}
let lo = hi - 1u;
let f = clamp((push - at[lo]) / max(at[hi] - at[lo], 1e-6), 0.0, 1.0);
return mix(film_curve(t, lo), film_curve(t, hi), f);
}", }",
}, },
crate::operation::Helper { crate::operation::Helper {
name: "film_lut", name: "film_lut",
source: "\ source: "\
// Trilinear interpolation of the density lookup, by hand for the same reason // Trilinear interpolation of one cube of the lookup, by hand for the same
// the curve above is: there is no sampler bound, and the eight loads are // reason the curve above is: there is no sampler bound, and the eight loads
// cache-neighbours. // are cache-neighbours. `z0` is where the cube starts in depth: the film's at
fn film_lut(t: vec3<f32>, size: f32) -> vec3<f32> { // zero, the paper's stacked after it.
fn film_lut(t: vec3<f32>, size: f32, z0: i32) -> vec3<f32> {
let n = i32(size); let n = i32(size);
let x = t * (size - 1.0); let x = t * (size - 1.0);
let base = min(vec3<i32>(floor(x)), vec3<i32>(n - 2)); let base = min(vec3<i32>(floor(x)), vec3<i32>(n - 2));
@@ -489,7 +635,7 @@ fn film_lut(t: vec3<f32>, size: f32) -> vec3<f32> {
let wy = select(1.0 - f.y, f.y, dy == 1); let wy = select(1.0 - f.y, f.y, dy == 1);
for (var dz = 0; dz < 2; dz = dz + 1) { for (var dz = 0; dz < 2; dz = dz + 1) {
let wz = select(1.0 - f.z, f.z, dz == 1); let wz = select(1.0 - f.z, f.z, dz == 1);
let p = base + vec3<i32>(dx, dy, dz); let p = base + vec3<i32>(dx, dy, dz + z0);
out = out + wx * wy * wz out = out + wx * wy * wz
* textureLoad(film_lut_texture, p, 0).rgb; * textureLoad(film_lut_texture, p, 0).rgb;
} }
@@ -513,7 +659,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0, density_max: 3.0,
lut_size: 32, lut_size: 32,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [3.0; 3], grain_density_max: [3.0; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
} }
@@ -558,10 +706,10 @@ mod tests {
#[test] #[test]
fn it_declares_itself_a_rendering_transform() { fn it_declares_itself_a_rendering_transform() {
// The whole reason the composer skips the base curve and the camera // The whole reason the composer emits the stock in the view
// matrix. If this ever returned false the picture would be rendered // transform's place rather than beside it. If this ever returned
// twice and converted twice, which looks like a colour management bug // false the picture would be rendered twice, which looks like a
// a long way from here. // colour management bug a long way from here.
assert!(FilmSim::new().renders()); assert!(FilmSim::new().renders());
} }
@@ -584,13 +732,15 @@ mod tests {
} }
#[test] #[test]
fn the_fragment_converts_out_of_camera_space_itself() { fn the_fragment_is_handed_working_space_colour() {
// It has to: it has taken over the conversion the composer would // TRACES: FR-DEV-3f
// otherwise emit at the end. // D19: the composer leaves camera space before any scene-stage
// operation, so a film converting again would apply the camera
// matrix twice.
let mut op = FilmSim::new(); let mut op = FilmSim::new();
op.set_tables(Some(tables())); op.set_tables(Some(tables()));
let wgsl = op.wgsl_body(); let wgsl = op.wgsl_body();
assert!(wgsl.contains("cam_to_srgb_0"), "{wgsl}"); assert!(!wgsl.contains("cam_to_srgb"), "{wgsl}");
} }
#[test] #[test]
+1 -2
View File
@@ -878,7 +878,6 @@ c = c * exp2(stops);"
mod tests { mod tests {
use super::*; use super::*;
use crate::detail::compose_detail; use crate::detail::compose_detail;
use dr_types::ColourSpace;
/// The two controls, as the graph would hold them. /// The two controls, as the graph would hold them.
fn ops(clarity: f32, texture: f32) -> Vec<Box<dyn Operation>> { fn ops(clarity: f32, texture: f32) -> Vec<Box<dyn Operation>> {
@@ -889,7 +888,7 @@ mod tests {
} }
fn composed(clarity: f32, texture: f32, scale: RenderScale) -> crate::ComposedDetail { fn composed(clarity: f32, texture: f32, scale: RenderScale) -> crate::ComposedDetail {
compose_detail(&ops(clarity, texture), scale, ColourSpace::Srgb) compose_detail(&ops(clarity, texture), scale)
} }
#[test] #[test]
+3 -1
View File
@@ -74,6 +74,7 @@ pub mod distortion;
pub mod film_sim; pub mod film_sim;
pub mod local_contrast; pub mod local_contrast;
pub mod noise_reduction; pub mod noise_reduction;
pub mod view_transform;
pub mod vignetting; pub mod vignetting;
pub use aberration::Aberration; pub use aberration::Aberration;
@@ -82,11 +83,12 @@ pub use colour_mixer::ColourMixer;
pub use curve::ToneCurve; pub use curve::ToneCurve;
pub use dehaze::Dehaze; pub use dehaze::Dehaze;
pub use distortion::Distortion; pub use distortion::Distortion;
pub use film_sim::{FilmSim, FilmTables}; pub use film_sim::{FilmSim, FilmTables, PaperTables};
// Clarity and texture are one implementation at two scales; see the module's // Clarity and texture are one implementation at two scales; see the module's
// documentation for why that is two nodes and not one. // documentation for why that is two nodes and not one.
pub use local_contrast::{Clarity, Texture}; pub use local_contrast::{Clarity, Texture};
pub use noise_reduction::NoiseReduction; pub use noise_reduction::NoiseReduction;
pub use view_transform::ViewTransform;
pub use vignetting::Vignetting; pub use vignetting::Vignetting;
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by // The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
+1 -7
View File
@@ -615,7 +615,6 @@ c = vec3<f32>(y0) + chroma_sum / weight_sum;";
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use dr_types::ColourSpace;
/// A 24 MP frame, and the panel a develop view might show it in. /// A 24 MP frame, and the panel a develop view might show it in.
const FULL: (u32, u32) = (6000, 4000); const FULL: (u32, u32) = (6000, 4000);
@@ -629,7 +628,7 @@ mod tests {
} }
fn compose(op: NoiseReduction, scale: RenderScale) -> crate::detail::ComposedDetail { fn compose(op: NoiseReduction, scale: RenderScale) -> crate::detail::ComposedDetail {
crate::detail::compose_detail(&chain_with(op), scale, ColourSpace::Srgb) crate::detail::compose_detail(&chain_with(op), scale)
} }
#[test] #[test]
@@ -674,11 +673,6 @@ mod tests {
// The luminance pass runs first, so the chroma guide is the denoised // The luminance pass runs first, so the chroma guide is the denoised
// luminance rather than the raw one. // luminance rather than the raw one.
assert_eq!(both.passes[0].label, "noise_reduction/luminance"); assert_eq!(both.passes[0].label, "noise_reduction/luminance");
// And only the last pass in the whole chain performs the output
// transform, whichever pass that happens to be.
assert!(!both.passes[0].writes_output);
assert!(!both.passes[1].writes_output);
assert!(both.passes[2].writes_output);
} }
#[test] #[test]
+211
View File
@@ -0,0 +1,211 @@
//! TRACES: FR-DEV-3j
//! The view transform as an operation: the photographer's two numbers for
//! the curve [`crate::view`] defines.
//!
//! # Why it is a node now, when the base curve could not be
//!
//! The base curve was kept out of the chain for reasons that were all about
//! the *body*: it was looked up by camera model, so as a node it would have
//! carried one camera's rendering onto another camera's file through a shared
//! sidecar, shown a dead slider on an unprofiled body, and opened a profiled
//! one reporting itself modified. D19 removed the premise. There is one view
//! transform for every body, so its settings are a decision about the picture
//! like any other, and they belong in the sidecar, the history and a mask
//! layer.
//!
//! # Why it is composed at its defaults
//!
//! A neutral operation is normally left out of the shader, and "active" means
//! "moved from its defaults". Both stay true here — an untouched photograph
//! writes no view transform parameters, and `every_node_starts_neutral` still
//! holds — but the composer emits this node whatever its state, because a
//! photograph with no view transform is a scan, not a picture. See
//! [`crate::operation::Stage::View`].
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::operation::{Helper, Operation, Stage, Uniform};
use crate::view::{Sigmoid, CONTRAST_RANGE, DEFAULT_CONTRAST, DEFAULT_WHITE, WHITE_RANGE};
pub const ID: OpId = OpId("view_transform");
pub const CONTRAST: ParamId = ParamId("contrast");
pub const WHITE: ParamId = ParamId("white");
static HELPERS: [Helper; 1] = [Helper {
name: "view_sigmoid",
source: crate::view::VIEW_SIGMOID_WGSL,
}];
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
// Tone: it is the tone response of the whole picture, and the panel's
// Light group is where a photographer looks for the white point.
attributes: vec![Attribute::Tone],
id: ID,
label: LocalizedKey("op.view_transform"),
params: vec![
ParamDescriptor::scalar(
"contrast",
"param.view_transform.contrast",
CONTRAST_RANGE.0,
CONTRAST_RANGE.1,
DEFAULT_CONTRAST,
Unit::None,
Scale::Linear,
2,
),
ParamDescriptor::scalar(
"white",
"param.view_transform.white",
WHITE_RANGE.0,
WHITE_RANGE.1,
DEFAULT_WHITE,
Unit::Stops,
Scale::Linear,
1,
),
],
})
});
#[derive(Debug, Clone)]
pub struct ViewTransform {
contrast: f32,
white: f32,
}
impl Default for ViewTransform {
fn default() -> Self {
Self {
contrast: DEFAULT_CONTRAST,
white: DEFAULT_WHITE,
}
}
}
impl ViewTransform {
pub fn new() -> Self {
Self::default()
}
/// The curve these settings solve to.
pub fn sigmoid(&self) -> Sigmoid {
Sigmoid::new(self.contrast, self.white)
}
}
impl Operation for ViewTransform {
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
CONTRAST => self.contrast = value,
WHITE => self.white = value,
_ => log::warn!("view_transform: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
CONTRAST => self.contrast,
WHITE => self.white,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
self.contrast != DEFAULT_CONTRAST || self.white != DEFAULT_WHITE
}
fn stage(&self) -> Stage {
Stage::View
}
fn wgsl_body(&self) -> String {
"\
// Skipped for an already-rendered source: a JPEG is a display rendering
// already, and rendering it again would compress it twice.
if (!non_linear) {
c = view_sigmoid(c, slope, inv_k, peak);
}"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
let s = self.sigmoid();
vec![
Uniform {
name: "slope",
value: s.n,
},
Uniform {
name: "inv_k",
value: s.inv_k,
},
Uniform {
name: "peak",
value: s.w,
},
]
}
fn helpers(&self) -> &[Helper] {
&HELPERS
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_starts_neutral_and_says_so() {
// TRACES: FR-DEV-3j
// Neutral in the sense every other node is: nothing moved, so nothing
// is written. It is still composed — see the module documentation.
let op = ViewTransform::new();
assert!(!op.is_active());
assert_eq!(op.sigmoid(), Sigmoid::default_curve());
}
#[test]
fn moving_either_slider_makes_it_active() {
let mut op = ViewTransform::new();
op.set_param(WHITE, 6.0);
assert!(op.is_active());
let mut op = ViewTransform::new();
op.set_param(CONTRAST, 2.0);
assert!(op.is_active());
}
#[test]
fn the_uniforms_are_the_solved_curve() {
let mut op = ViewTransform::new();
op.set_param(CONTRAST, 2.0);
op.set_param(WHITE, 6.0);
let s = Sigmoid::new(2.0, 6.0);
let u = op.uniforms();
assert_eq!(
u.iter().map(|u| u.value).collect::<Vec<_>>(),
vec![s.n, s.inv_k, s.w]
);
}
#[test]
fn the_descriptor_defaults_are_the_curve_defaults() {
// The sidecar treats a value equal to the descriptor's default as
// unedited; the two disagreeing would make every photograph open
// reporting a view transform edit it never had.
let d = ViewTransform::new().descriptor();
assert_eq!(
d.param(CONTRAST).expect("contrast").default,
DEFAULT_CONTRAST
);
assert_eq!(d.param(WHITE).expect("white").default, DEFAULT_WHITE);
}
}
+439 -23
View File
@@ -44,6 +44,7 @@ use std::fmt::Write as _;
use crate::descriptor::{Attribute, OpId, ParamId}; use crate::descriptor::{Attribute, OpId, ParamId};
use crate::graph::EditGraph; use crate::graph::EditGraph;
use crate::state::{FilmRebake, FilmRef};
/// Which parts of an edit a copy carries. /// Which parts of an edit a copy carries.
/// ///
@@ -173,6 +174,15 @@ impl Scope {
self.bits == 0 self.bits == 0
} }
/// Whether this scope carries the film — the stock as well as its sliders.
///
/// Asked of the film node's own classification rather than decided here,
/// so the stock travels under exactly the scopes its exposure slider
/// does. Splitting the two would put one stock's exposure on another.
pub fn carries_film(self) -> bool {
self.covers(crate::ops::film_sim::ID.0)
}
fn bit(attribute: Attribute) -> u8 { fn bit(attribute: Attribute) -> u8 {
1 << Attribute::ALL 1 << Attribute::ALL
.iter() .iter()
@@ -216,6 +226,17 @@ fn attributes_of(op: &str) -> Option<&'static [Attribute]> {
TABLE.get(op).map(Vec::as_slice) TABLE.get(op).map(Vec::as_slice)
} }
/// How much of the target an applied preset replaces. See the module note.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Reach {
/// Everything in scope: absence means default. A copy, a saved edit.
#[default]
Whole,
/// Only the operations the preset names, and the film if it names one.
/// Everything else in the target is left as it was. A look.
Named,
}
/// A set of non-default parameter values, ready to apply elsewhere. /// A set of non-default parameter values, ready to apply elsewhere.
/// ///
/// Ordered, so two captures of the same edit compare equal and a caller can /// Ordered, so two captures of the same edit compare equal and a caller can
@@ -223,6 +244,10 @@ fn attributes_of(op: &str) -> Option<&'static [Attribute]> {
#[derive(Debug, Clone, PartialEq, Default)] #[derive(Debug, Clone, PartialEq, Default)]
pub struct Preset { pub struct Preset {
params: BTreeMap<(String, String), f32>, params: BTreeMap<(String, String), f32>,
reach: Reach,
/// TRACES: FR-DEV-3f
/// The stock this edit develops on, by id. See the module note.
film: Option<FilmRef>,
} }
impl Preset { impl Preset {
@@ -237,6 +262,21 @@ impl Preset {
/// have to be re-copied to change one's mind about framing, and a preset /// have to be re-copied to change one's mind about framing, and a preset
/// that had already discarded the crop could never grow it back. /// that had already discarded the crop could never grow it back.
pub fn capture(graph: &EditGraph) -> Self { pub fn capture(graph: &EditGraph) -> Self {
Self {
film: graph.film().map(|f| FilmRef {
stock: f.stock.clone(),
print: f.print.clone(),
}),
..Self::capture_params(graph)
}
}
/// The parameters alone, without the film.
///
/// For [`EditGraph::state`], which carries the film in a field of its own
/// ([`crate::EditState::film`]). Capturing it twice would give one edit
/// two places to disagree about its stock.
pub(crate) fn capture_params(graph: &EditGraph) -> Self {
let mut params = BTreeMap::new(); let mut params = BTreeMap::new();
for cap in graph.capabilities() { for cap in graph.capabilities() {
for p in &cap.params { for p in &cap.params {
@@ -245,12 +285,59 @@ impl Preset {
} }
} }
} }
Self { params } Self::from_params(params)
} }
/// Build from an already-captured parameter map — a sidecar's, typically. /// Build from an already-captured parameter map — a sidecar's, typically.
pub fn from_params(params: BTreeMap<(String, String), f32>) -> Self { pub fn from_params(params: BTreeMap<(String, String), f32>) -> Self {
Self { params } Self {
params,
reach: Reach::Whole,
film: None,
}
}
/// The same preset, reaching as far as `reach` says.
pub fn with_reach(self, reach: Reach) -> Self {
Self { reach, ..self }
}
/// How much of the target this preset replaces.
pub fn reach(&self) -> Reach {
self.reach
}
/// Whether applying this preset replaces `op` — before any scope is asked.
fn reaches(&self, op: &str) -> bool {
match self.reach {
Reach::Whole => true,
Reach::Named => {
self.params.keys().any(|(o, _)| o == op)
|| (self.film.is_some() && op == crate::ops::film_sim::ID.0)
}
}
}
/// The same preset, developed on `film`.
pub fn with_film(self, film: Option<FilmRef>) -> Self {
Self { film, ..self }
}
/// The stock this preset develops on, if it names one.
pub fn film(&self) -> Option<&FilmRef> {
self.film.as_ref()
}
/// What applying at `scope` does to the target's film.
///
/// `None` when the scope leaves the film alone; `Some(None)` when it
/// develops the target without one. The same two levels the sidecar
/// writer takes, which is who asks: the batch path amends files rather
/// than graphs, and has to know whether the film line is being written at
/// all before it knows what to write.
pub fn film_for(&self, scope: Scope) -> Option<Option<&FilmRef>> {
(scope.carries_film() && self.reaches(crate::ops::film_sim::ID.0))
.then_some(self.film.as_ref())
} }
/// The parameters, for a caller that stores them. /// The parameters, for a caller that stores them.
@@ -270,7 +357,7 @@ impl Preset {
/// answers is whether there is a clipboard to offer, which is why the UI /// answers is whether there is a clipboard to offer, which is why the UI
/// asks it before enabling a paste. /// asks it before enabling a paste.
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.params.is_empty() self.params.is_empty() && self.film.is_none()
} }
/// How many parameters were captured. /// How many parameters were captured.
@@ -296,10 +383,14 @@ impl Preset {
/// rather than parameters because thirty-six mixer sliders is a number /// rather than parameters because thirty-six mixer sliders is a number
/// about the mixer's shape, not about how much was copied. /// about the mixer's shape, not about how much was copied.
pub fn op_count(&self, scope: Scope) -> usize { pub fn op_count(&self, scope: Scope) -> usize {
// A stock with every film slider at default is still the film node
// at work, and a preset holding nothing else is not "Neutral".
let film = self.film.is_some().then_some(crate::ops::film_sim::ID.0);
let mut ops: Vec<&str> = self let mut ops: Vec<&str> = self
.params .params
.keys() .keys()
.map(|(op, _)| op.as_str()) .map(|(op, _)| op.as_str())
.chain(film)
.filter(|op| scope.covers(op)) .filter(|op| scope.covers(op))
.collect(); .collect();
ops.sort_unstable(); ops.sort_unstable();
@@ -319,7 +410,13 @@ impl Preset {
/// to a fitted view mid-comparison, which reads as the paste having /// to a fitted view mid-comparison, which reads as the paste having
/// navigated somewhere. This mirrors `DevelopSession::reset_framing`, and /// navigated somewhere. This mirrors `DevelopSession::reset_framing`, and
/// lives here so every caller inherits it rather than each remembering. /// lives here so every caller inherits it rather than each remembering.
pub fn apply(&self, graph: &mut EditGraph, scope: Scope) { ///
/// The **film** is replaced when the scope carries it, and handed back as
/// a [`FilmRebake`] because this crate cannot bake a stock — the same debt
/// [`EditGraph::set_state`] returns, for the same reason. It is cleared
/// even when the preset names the stock already on the graph: the tables
/// were baked from the film sliders this call has just replaced.
pub fn apply(&self, graph: &mut EditGraph, scope: Scope) -> FilmRebake {
let view = graph.framing().view(); let view = graph.framing().view();
// Clear the scope first, so absence means default (see the module // Clear the scope first, so absence means default (see the module
@@ -328,7 +425,7 @@ impl Preset {
let clears: Vec<(OpId, ParamId, f32)> = graph let clears: Vec<(OpId, ParamId, f32)> = graph
.capabilities() .capabilities()
.iter() .iter()
.filter(|cap| scope.covers(cap.id.0)) .filter(|cap| scope.covers(cap.id.0) && self.reaches(cap.id.0))
.flat_map(|cap| cap.params.iter().map(|p| (cap.id, p.id, p.default))) .flat_map(|cap| cap.params.iter().map(|p| (cap.id, p.id, p.default)))
.collect(); .collect();
for (op, param, default) in clears { for (op, param, default) in clears {
@@ -347,6 +444,17 @@ impl Preset {
} }
graph.framing_mut().set_view(view); graph.framing_mut().set_view(view);
match self.film_for(scope) {
None => FilmRebake::NotNeeded,
Some(film) => {
graph.set_film(None);
match film {
None => FilmRebake::NotNeeded,
Some(film) => FilmRebake::Wanted(film.clone()),
}
}
}
} }
/// Apply to a parameter map — the sidecar of an image that is not open. /// Apply to a parameter map — the sidecar of an image that is not open.
@@ -361,8 +469,11 @@ impl Preset {
/// Same replacement rule as [`Self::apply`]: the target's in-scope keys go, /// Same replacement rule as [`Self::apply`]: the target's in-scope keys go,
/// the preset's arrive, and out-of-scope keys — the target's own crop, on /// the preset's arrive, and out-of-scope keys — the target's own crop, on
/// the default scope — are left exactly as they were. /// the default scope — are left exactly as they were.
///
/// A parameter map has no film in it, so the stock is not written here:
/// the caller asks [`Self::film_for`] and writes it beside the map.
pub fn amend(&self, target: &mut BTreeMap<(String, String), f32>, scope: Scope) { pub fn amend(&self, target: &mut BTreeMap<(String, String), f32>, scope: Scope) {
target.retain(|(op, _), _| !scope.covers(op)); target.retain(|(op, _), _| !(scope.covers(op) && self.reaches(op)));
for ((op, param), value) in &self.params { for ((op, param), value) in &self.params {
if scope.covers(op) { if scope.covers(op) {
target.insert((op.clone(), param.clone()), *value); target.insert((op.clone(), param.clone()), *value);
@@ -556,6 +667,14 @@ impl PresetLibrary {
self.presets.is_empty() self.presets.is_empty()
} }
/// How many lines were kept without being understood.
///
/// For a file this build wrote itself, or shipped, that should be none: a
/// misspelt key is preserved faithfully and does nothing.
pub fn unread_lines(&self) -> usize {
self.unknown.values().map(Vec::len).sum()
}
/// Serialise to the on-disk form. /// Serialise to the on-disk form.
/// ///
/// Deterministic, like the sidecar's: the same library always produces the /// Deterministic, like the sidecar's: the same library always produces the
@@ -567,6 +686,22 @@ impl PresetLibrary {
for ((op, param), value) in preset.params() { for ((op, param), value) in preset.params() {
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value)); let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
} }
// TRACES: FR-DEV-3f
// Spelled as the sidecar spells them, so a block can still be
// pasted from one file into the other. A build that predates
// these lines reads them as lines it does not understand and
// writes them back untouched, which is the promise below.
// Only when it differs from the default, so every library written
// before looks existed still writes the same bytes.
if preset.reach() == Reach::Named {
let _ = writeln!(out, "reach = named");
}
if let Some(film) = preset.film() {
let _ = writeln!(out, "film = {}", film.stock);
if let Some(print) = &film.print {
let _ = writeln!(out, "film_print = {print}");
}
}
for line in self.unknown.get(name).into_iter().flatten() { for line in self.unknown.get(name).into_iter().flatten() {
let _ = writeln!(out, "{line}"); let _ = writeln!(out, "{line}");
} }
@@ -597,6 +732,8 @@ impl PresetLibrary {
let mut library = Self::default(); let mut library = Self::default();
let mut current: Option<String> = None; let mut current: Option<String> = None;
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new(); let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
let mut film: Option<FilmRef> = None;
let mut reach = Reach::Whole;
for line in lines { for line in lines {
let line = line.trim(); let line = line.trim();
@@ -609,9 +746,16 @@ impl PresetLibrary {
.and_then(|l| l.strip_suffix(']')) .and_then(|l| l.strip_suffix(']'))
{ {
if let Some(name) = current.take() { if let Some(name) = current.take() {
library.presets.insert(name, Preset::from_params(params)); library.presets.insert(
params = BTreeMap::new(); name,
Preset::from_params(std::mem::take(&mut params))
.with_film(film.take())
.with_reach(reach),
);
} }
params.clear();
film = None;
reach = Reach::Whole;
// A name the writer should never have produced is dropped // A name the writer should never have produced is dropped
// rather than taken: accepting it would mean writing a file // rather than taken: accepting it would mean writing a file
// back out that no longer parses as this one. // back out that no longer parses as this one.
@@ -631,6 +775,23 @@ impl PresetLibrary {
}; };
match line.split_once('=') { match line.split_once('=') {
// TRACES: FR-DEV-3f
// Not checked against the installed stocks: this crate does
// not link them, and a preset naming a stock this device
// lacks must survive being stored here. Whoever bakes it
// reports the miss — the sidecar's rule, for its reason.
// `get_or_insert_with` because a hand-edited block may name
// the paper first.
Some((key, value)) if key.trim() == "reach" && value.trim() == "named" => {
reach = Reach::Named;
}
Some((key, value)) if key.trim() == "film" && !value.trim().is_empty() => {
film.get_or_insert_with(FilmRef::default).stock = value.trim().to_string();
}
Some((key, value)) if key.trim() == "film_print" && !value.trim().is_empty() => {
film.get_or_insert_with(FilmRef::default).print =
Some(value.trim().to_string());
}
Some((key, value)) => { Some((key, value)) => {
let key = key.trim(); let key = key.trim();
let value = value.trim(); let value = value.trim();
@@ -654,7 +815,21 @@ impl PresetLibrary {
} }
if let Some(name) = current { if let Some(name) = current {
library.presets.insert(name, Preset::from_params(params)); library.presets.insert(
name,
Preset::from_params(params)
.with_film(film)
.with_reach(reach),
);
}
// A paper with no film named beside it is a print of nothing. Dropped
// rather than kept, since baking it would have no stock to start from.
for preset in library.presets.values_mut() {
if preset.film.as_ref().is_some_and(|f| f.stock.is_empty()) {
log::warn!("preset library: a paper without a film; ignoring it");
preset.film = None;
}
} }
// A block whose every line was unreadable still produced a preset, and // A block whose every line was unreadable still produced a preset, and
@@ -803,7 +978,9 @@ mod tests {
}; };
target.set_crop(target_crop); target.set_crop(target_crop);
preset.apply(&mut target, Scope::adjustments()); preset
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!( assert_eq!(
target.param(exposure::ID, exposure::EXPOSURE), target.param(exposure::ID, exposure::EXPOSURE),
@@ -835,7 +1012,9 @@ mod tests {
fn pasting_everything_carries_the_composition_too() { fn pasting_everything_carries_the_composition_too() {
let preset = Preset::capture(&edited()); let preset = Preset::capture(&edited());
let mut target = EditGraph::default_chain(); let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::everything()); preset
.apply(&mut target, Scope::everything())
.expect_no_film();
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0)); assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
assert_eq!(target.param(framing::ID, framing::KEYSTONE_V), Some(40.0)); assert_eq!(target.param(framing::ID, framing::KEYSTONE_V), Some(40.0));
@@ -857,7 +1036,9 @@ mod tests {
target.set_param(exposure::ID, exposure::EXPOSURE, 2.0); target.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
target.set_param(saturation::ID, saturation::SATURATION, -50.0); target.set_param(saturation::ID, saturation::SATURATION, -50.0);
neutral.apply(&mut target, Scope::adjustments()); neutral
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.0)); assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
assert_eq!( assert_eq!(
@@ -876,7 +1057,9 @@ mod tests {
let mut target = EditGraph::default_chain(); let mut target = EditGraph::default_chain();
target.set_param(framing::ID, framing::ANGLE, 3.5); target.set_param(framing::ID, framing::ANGLE, 3.5);
neutral.apply(&mut target, Scope::adjustments()); neutral
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(3.5)); assert_eq!(target.param(framing::ID, framing::ANGLE), Some(3.5));
} }
@@ -894,7 +1077,9 @@ mod tests {
height: 0.25, height: 0.25,
}); });
preset.apply(&mut target, Scope::everything()); preset
.apply(&mut target, Scope::everything())
.expect_no_film();
assert!( assert!(
target.framing().is_zoomed(), target.framing().is_zoomed(),
"the paste threw away the viewport: {:?}", "the paste threw away the viewport: {:?}",
@@ -911,7 +1096,9 @@ mod tests {
let mut sideways = EditGraph::default_chain(); let mut sideways = EditGraph::default_chain();
sideways.set_orientation(dr_types::Orientation::from_exif(6)); sideways.set_orientation(dr_types::Orientation::from_exif(6));
preset.apply(&mut sideways, Scope::everything()); preset
.apply(&mut sideways, Scope::everything())
.expect_no_film();
assert_eq!( assert_eq!(
sideways.framing().baseline(), sideways.framing().baseline(),
@@ -928,7 +1115,9 @@ mod tests {
params.insert(("exposure".to_string(), "exposure".to_string()), 1.25); params.insert(("exposure".to_string(), "exposure".to_string()), 1.25);
let mut target = EditGraph::default_chain(); let mut target = EditGraph::default_chain();
Preset::from_params(params).apply(&mut target, Scope::adjustments()); Preset::from_params(params)
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25)); assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
} }
@@ -939,7 +1128,9 @@ mod tests {
params.insert(("exposure".to_string(), "exposure".to_string()), 99.0); params.insert(("exposure".to_string(), "exposure".to_string()), 99.0);
let mut target = EditGraph::default_chain(); let mut target = EditGraph::default_chain();
Preset::from_params(params).apply(&mut target, Scope::adjustments()); Preset::from_params(params)
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(5.0)); assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
} }
@@ -1001,11 +1192,13 @@ mod tests {
let mut target_map = Preset::capture(&target_graph).into_params(); let mut target_map = Preset::capture(&target_graph).into_params();
preset.apply(&mut target_graph, scope); preset.apply(&mut target_graph, scope).expect_no_film();
preset.amend(&mut target_map, scope); preset.amend(&mut target_map, scope);
let mut rebuilt = EditGraph::default_chain(); let mut rebuilt = EditGraph::default_chain();
Preset::from_params(target_map).apply(&mut rebuilt, Scope::everything()); Preset::from_params(target_map)
.apply(&mut rebuilt, Scope::everything())
.expect_no_film();
for cap in target_graph.capabilities() { for cap in target_graph.capabilities() {
for p in &cap.params { for p in &cap.params {
@@ -1030,7 +1223,9 @@ mod tests {
let preset = Preset::capture(&source); let preset = Preset::capture(&source);
let mut target = EditGraph::default_chain(); let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::everything()); preset
.apply(&mut target, Scope::everything())
.expect_no_film();
for cap in source.capabilities() { for cap in source.capabilities() {
for p in &cap.params { for p in &cap.params {
@@ -1068,6 +1263,190 @@ mod tests {
assert_eq!(excluded, vec![framing::ID.0]); assert_eq!(excluded, vec![framing::ID.0]);
} }
// --- the film ------------------------------------------------------------
/// A graph developing on `stock`. The tables are invented; what is under
/// test is whether the *choice* travels.
fn on_film(stock: &str) -> EditGraph {
let mut g = edited();
g.set_film(Some(crate::graph::Film {
stock: stock.to_string(),
print: Some("kodak_portra_endura".to_string()),
tables: crate::ops::FilmTables {
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
curve_log_min: -3.0,
curve_log_max: 1.0,
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
lut_size: 2,
grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
}));
g
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
#[test]
fn a_copy_carries_the_stock_it_was_developed_on() {
let preset = Preset::capture(&on_film("kodak_portra_400"));
let film = preset.film().expect("the stock was captured");
assert_eq!(film.stock, "kodak_portra_400");
assert_eq!(film.print.as_deref(), Some("kodak_portra_endura"));
assert!(Preset::capture(&edited()).film().is_none());
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// The graph's own state keeps the film in one place only.
#[test]
fn an_edit_state_does_not_carry_the_film_twice() {
let state = on_film("kodak_portra_400").state();
assert!(state.film.is_some());
assert_eq!(state.params.film(), None);
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// Applying a preset that names a stock asks for it to be baked, and
/// clears the tables it found — they came from the sliders it replaced.
#[test]
fn applying_a_film_preset_asks_for_its_stock() {
let preset = Preset::capture(&on_film("kodak_portra_400"));
let mut target = on_film("ilford_hp5");
let rebake = preset.apply(&mut target, Scope::adjustments());
assert_eq!(
rebake.wanted().map(|f| f.stock.as_str()),
Some("kodak_portra_400")
);
assert!(target.film().is_none(), "the old tables were left standing");
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// Replacement, as for every parameter: a preset with no stock, at a
/// scope that carries the film, develops the target without one.
#[test]
fn a_preset_without_a_film_clears_the_targets() {
let mut target = on_film("ilford_hp5");
Preset::capture(&edited())
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert!(target.film().is_none());
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// A scope that leaves the film node behind leaves the stock behind too,
/// so an exposure never lands on a stock it was not set for.
#[test]
fn a_scope_without_the_film_leaves_the_stock_alone() {
let preset = Preset::capture(&on_film("kodak_portra_400"));
let mut target = on_film("ilford_hp5");
let tone = Scope::of([Attribute::Tone]);
assert!(!tone.carries_film());
preset.apply(&mut target, tone).expect_no_film();
assert_eq!(target.film().map(|f| f.stock.as_str()), Some("ilford_hp5"));
assert_eq!(preset.film_for(tone), None);
assert!(preset.film_for(Scope::adjustments()).is_some());
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
#[test]
fn a_stock_alone_is_not_a_neutral_preset() {
let preset = Preset::default().with_film(Some(FilmRef {
stock: "kodak_portra_400".into(),
print: None,
}));
assert!(!preset.is_empty());
assert_eq!(preset.op_count(Scope::adjustments()), 1);
assert_eq!(preset.op_count(Scope::of([Attribute::Tone])), 0);
}
// --- reach ---------------------------------------------------------------
/// A look: a stock and a contrast, and nothing else.
fn look() -> Preset {
let mut params = BTreeMap::new();
params.insert(("contrast".to_string(), "contrast".to_string()), 20.0);
Preset::from_params(params)
.with_film(Some(FilmRef {
stock: "kodak_portra_400".into(),
print: None,
}))
.with_reach(Reach::Named)
}
/// TRACES: FR-DEV-6
/// The reason `Reach` exists: a look applied over a corrected photograph
/// keeps the correction.
#[test]
fn a_look_leaves_what_it_does_not_name_alone() {
let mut target = EditGraph::default_chain();
target.set_param(exposure::ID, exposure::EXPOSURE, 1.25);
target.set_param(saturation::ID, saturation::SATURATION, -40.0);
let rebake = look().apply(&mut target, Scope::adjustments());
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
assert_eq!(
target.param(saturation::ID, saturation::SATURATION),
Some(-40.0)
);
assert_eq!(
rebake.wanted().map(|f| f.stock.as_str()),
Some("kodak_portra_400")
);
}
/// TRACES: FR-DEV-6
/// A look without a film leaves the target's film alone, where a whole
/// edit without one would clear it.
#[test]
fn a_look_without_a_film_keeps_the_targets() {
let mut target = on_film("ilford_hp5");
let only_contrast = look().with_film(None);
assert_eq!(only_contrast.film_for(Scope::adjustments()), None);
only_contrast
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.film().map(|f| f.stock.as_str()), Some("ilford_hp5"));
}
/// TRACES: FR-DEV-6
/// The batch path agrees: a look amends only the operations it names.
#[test]
fn a_look_amends_only_what_it_names() {
let mut target = BTreeMap::new();
target.insert(("exposure".to_string(), "exposure".to_string()), 1.25);
target.insert(("contrast".to_string(), "contrast".to_string()), -50.0);
look().amend(&mut target, Scope::adjustments());
assert_eq!(
target.get(&("exposure".to_string(), "exposure".to_string())),
Some(&1.25)
);
assert_eq!(
target.get(&("contrast".to_string(), "contrast".to_string())),
Some(&20.0)
);
}
/// TRACES: FR-DEV-6
#[test]
fn a_look_survives_the_library_round_trip() {
let mut lib = named();
lib.insert("Look", look()).unwrap();
let text = lib.to_text();
assert!(text.contains("reach = named\n"), "{text}");
let back = PresetLibrary::parse(&text).unwrap();
assert_eq!(back, lib);
// And the default is not written, so an existing library's bytes are
// unchanged by this format ever having grown the line.
assert_eq!(named().to_text().matches("reach").count(), 0);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Named presets // Named presets
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -1147,6 +1526,37 @@ mod tests {
assert!(out.contains("not_an_op.not_a_param = 0.25"), "{out}"); assert!(out.contains("not_an_op.not_a_param = 0.25"), "{out}");
} }
/// TRACES: FR-DEV-6 | FR-DEV-3f
#[test]
fn a_film_survives_the_library_round_trip() {
let mut lib = named();
lib.insert("Portra", Preset::capture(&on_film("kodak_portra_400")))
.unwrap();
let text = lib.to_text();
assert!(text.contains("film = kodak_portra_400\n"), "{text}");
assert!(
text.contains("film_print = kodak_portra_endura\n"),
"{text}"
);
assert_eq!(PresetLibrary::parse(&text).unwrap(), lib);
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// The paper may come first in a hand-edited block, and a paper with no
/// film is dropped rather than baked from nothing.
#[test]
fn film_lines_read_in_either_order_and_a_lone_paper_is_dropped() {
let text = format!(
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset A]\nfilm_print = p\nfilm = s\n\n\
[preset B]\nfilm_print = p\nexposure.exposure = 1\n"
);
let lib = PresetLibrary::parse(&text).unwrap();
let a = lib.get("A").unwrap().film().unwrap();
assert_eq!((a.stock.as_str(), a.print.as_deref()), ("s", Some("p")));
assert_eq!(lib.get("B").unwrap().film(), None);
assert_eq!(lib.get("B").unwrap().len(), 1);
}
#[test] #[test]
fn a_file_from_a_newer_build_is_refused_rather_than_guessed_at() { fn a_file_from_a_newer_build_is_refused_rather_than_guessed_at() {
let text = format!("drpl {}\n", LIBRARY_FORMAT_VERSION + 1); let text = format!("drpl {}\n", LIBRARY_FORMAT_VERSION + 1);
@@ -1234,7 +1644,9 @@ mod tests {
let preset = stored.get("Warm portrait").unwrap(); let preset = stored.get("Warm portrait").unwrap();
let mut target = EditGraph::default_chain(); let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::adjustments()); preset
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.75)); assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
// The target keeps its own framing on the default scope. // The target keeps its own framing on the default scope.
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(0.0)); assert_eq!(target.param(framing::ID, framing::ANGLE), Some(0.0));
@@ -1268,7 +1680,9 @@ mod tests {
fn a_hand_picked_scope_carries_only_the_kinds_it_names() { fn a_hand_picked_scope_carries_only_the_kinds_it_names() {
let preset = Preset::capture(&edited()); let preset = Preset::capture(&edited());
let mut target = EditGraph::default_chain(); let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::of([Attribute::Tone])); preset
.apply(&mut target, Scope::of([Attribute::Tone]))
.expect_no_film();
// Tone was picked, so the exposure travelled. // Tone was picked, so the exposure travelled.
assert_eq!( assert_eq!(
@@ -1333,7 +1747,9 @@ mod tests {
let mut target = EditGraph::default_chain(); let mut target = EditGraph::default_chain();
target.set_param(exposure::ID, exposure::EXPOSURE, -1.25); target.set_param(exposure::ID, exposure::EXPOSURE, -1.25);
Preset::capture(&edited()).apply(&mut target, empty); Preset::capture(&edited())
.apply(&mut target, empty)
.expect_no_film();
assert_eq!( assert_eq!(
target.param(exposure::ID, exposure::EXPOSURE), target.param(exposure::ID, exposure::EXPOSURE),
Some(-1.25), Some(-1.25),
+3 -1
View File
@@ -2023,7 +2023,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8], lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0, density_max: 2.0,
lut_size: 2, lut_size: 2,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [2.0; 3], grain_density_max: [2.0; 3],
grain_uniformity: 1.0, grain_uniformity: 1.0,
}, },
-220
View File
@@ -1,220 +0,0 @@
//! TRACES: FR-DEV-6
//! The presets a first run starts with.
//!
//! # Why any at all
//!
//! A preset sheet that opens on "No presets yet" teaches the photographer that
//! the feature is homework. These exist so the first thing the sheet does is
//! demonstrate what a preset *is* — and so that applying one to a selection,
//! which is the action worth discovering, is available before anybody has
//! saved anything.
//!
//! # Why these are ours and not Adobe's
//!
//! Lightroom ships a large bundled set, and importing one of *those* files is
//! what [`crate::preset_import`] is for — a photographer's own library,
//! carried across. Redistributing Adobe's inside this application would be
//! shipping their creative work under a licence that does not permit it, which
//! is a reason on its own; and their numbers are calibrated against their tone
//! curve rather than ours, so the look would not survive the trip even if the
//! licence allowed it.
//!
//! So these are written against this pipeline, in its units, and they are
//! deliberately mild. A starter preset is a starting point — a photographer
//! who wanted the full effect can push the sliders, where one who is handed a
//! caricature learns to distrust the list.
//!
//! # Why they live in the core rather than in the interface
//!
//! Because they name operations, and nothing in `ui/` may
//! (`ui_names_no_operation.rs`, ARCH §4.3a). That test is right to object: a
//! preset called "Punch" *is* a statement about contrast, clarity and
//! vibrance, which makes it a statement in the pipeline's vocabulary rather
//! than a fact about any interface. The frontend asks for the set and stores
//! it; it never learns what is in it.
//!
//! # Why they are seeded rather than merged
//!
//! Written once, on the first run that finds no library at all, and never
//! again. Re-adding them on every start would resurrect one the photographer
//! deleted on purpose, and updating them in place would silently rewrite an
//! edit they had adjusted and kept under the same name. After the first run
//! these are ordinary presets: renameable, editable, deletable, and gone for
//! good when deleted.
use std::collections::BTreeMap;
use crate::{Preset, PresetLibrary};
/// One starter preset: a name and the parameters that differ from default.
struct Starter {
name: &'static str,
params: &'static [(&'static str, &'static str, f32)],
}
/// The set. Small on purpose — six a photographer might actually reach for
/// beats forty they have to scroll past.
const STARTERS: &[Starter] = &[
Starter {
name: "Punch",
params: &[
("contrast", "contrast", 18.0),
("clarity", "amount", 12.0),
("vibrance", "vibrance", 18.0),
("blacks_whites", "blacks", -8.0),
],
},
Starter {
name: "Soft portrait",
params: &[
("contrast", "contrast", -8.0),
("highlights_shadows", "highlights", -20.0),
("highlights_shadows", "shadows", 15.0),
("clarity", "amount", -10.0),
("vibrance", "vibrance", 10.0),
("saturation", "saturation", -5.0),
],
},
Starter {
name: "Recover the sky",
params: &[
// The most common single fix in landscape work: a bright sky and a
// dark foreground, both pulled back toward the middle.
("highlights_shadows", "highlights", -55.0),
("highlights_shadows", "shadows", 35.0),
("blacks_whites", "whites", -10.0),
],
},
Starter {
name: "Lift the shadows",
params: &[
("highlights_shadows", "shadows", 40.0),
("blacks_whites", "blacks", 12.0),
("contrast", "contrast", -5.0),
],
},
Starter {
name: "Crisp detail",
params: &[
("texture", "amount", 20.0),
("clarity", "amount", 10.0),
("capture_sharpen", "amount", 35.0),
],
},
Starter {
name: "Muted",
params: &[
("saturation", "saturation", -30.0),
("vibrance", "vibrance", 10.0),
("contrast", "contrast", -10.0),
("highlights_shadows", "shadows", 12.0),
],
},
];
/// The starter library, for a device that has never had one.
pub fn library() -> PresetLibrary {
let mut library = PresetLibrary::default();
for starter in STARTERS {
let params: BTreeMap<(String, String), f32> = starter
.params
.iter()
.map(|(op, param, value)| ((op.to_string(), param.to_string()), *value))
.collect();
// The name is a literal in this file, so a refusal would be a bug here
// rather than bad input — but it still must not take the whole set
// down, since the alternative to five presets is not six, it is none.
if let Err(e) = library.insert(starter.name, Preset::from_params(params)) {
log::warn!(
"starter preset {:?} is unusable ({e:?}); skipping",
starter.name
);
}
}
library
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EditGraph, Scope};
#[test]
fn every_starter_names_parameters_this_build_actually_has() {
// The same guard the importer's table has, for the same reason: a
// renamed parameter must break the build rather than ship a preset
// that quietly does nothing.
let graph = EditGraph::default_chain();
let capabilities = graph.capabilities();
for starter in STARTERS {
for (op, param, _) in starter.params {
let capability = capabilities
.iter()
.find(|c| c.id.0 == *op)
.unwrap_or_else(|| panic!("{:?}: no operation {op:?}", starter.name));
assert!(
capability.params.iter().any(|p| p.id.0 == *param),
"{:?}: operation {op:?} has no parameter {param:?}",
starter.name
);
}
}
}
#[test]
fn every_starter_actually_changes_something() {
// A preset that applies to nothing is worse than one fewer preset: it
// teaches the photographer that the list does not work.
for (name, preset) in library().iter() {
assert!(!preset.is_empty(), "{name} carries nothing");
let mut graph = EditGraph::default_chain();
preset.apply(&mut graph, Scope::adjustments());
assert_ne!(
Preset::capture(&graph),
Preset::default(),
"{name} left the graph at its defaults"
);
}
}
#[test]
fn no_starter_carries_a_crop() {
// These are looks, not compositions. One that re-framed every image it
// was applied to would be the exact accident `Scope`'s default exists
// to prevent.
for (name, preset) in library().iter() {
assert!(!preset.touches_framing(), "{name} carries framing");
}
}
#[test]
fn the_names_are_distinct() {
assert_eq!(library().len(), STARTERS.len());
}
#[test]
fn the_values_stay_inside_what_the_controls_accept() {
// Clamping happens on apply, so an out-of-range literal here would be
// silently trimmed and the preset would not be the one written.
let graph = EditGraph::default_chain();
for starter in STARTERS {
for (op, param, value) in starter.params {
let mut applied = EditGraph::default_chain();
let capability = graph
.capabilities()
.into_iter()
.find(|c| c.id.0 == *op)
.unwrap();
let descriptor = capability.params.iter().find(|p| p.id.0 == *param).unwrap();
applied.set_param(capability.id, descriptor.id, *value);
assert_eq!(
applied.param(capability.id, descriptor.id),
Some(*value),
"{}: {op}.{param} = {value} was clamped",
starter.name
);
}
}
}
}
+3 -1
View File
@@ -235,7 +235,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8], lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0, density_max: 2.0,
lut_size: 2, lut_size: 2,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [2.0; 3], grain_density_max: [2.0; 3],
grain_uniformity: 1.0, grain_uniformity: 1.0,
}, },
+155
View File
@@ -0,0 +1,155 @@
//! TRACES: FR-DSP-2 | NFR-RES-2
//! Cutting a render too large for one texture into tiles.
//!
//! The interactive path is not tiled, and on the evidence should not be
//! (`docs/dev/frame-budget.md`, TD-4): one fused dispatch over a viewport is
//! inside the frame budget, and a halo per tile nearly doubles the taps of a
//! wide kernel. What does not fit is a *file*. A 22927×8966 panorama has no
//! render target on a device whose textures stop at 16384, so its export, and
//! nothing else, is drawn a tile at a time.
//!
//! A tile is two rectangles in pixels of the framed output: the one rendered,
//! grown by the detail stage's reach ([`crate::ComposedDetail::reach`]) so
//! every kernel near its edge reads the pixels it would read untiled, and the
//! one kept, which is the tile proper. The kept rectangles cover the frame
//! exactly once.
//!
//! The rendered rectangle's origin is aligned to [`TILE_ALIGN`]. The detail
//! stage computes clarity's base on a reduced grid, and a tile starting half
//! way through a reduced texel would reduce different pixels together than
//! the untiled frame does, which shows as a faint seam.
/// A multiple of every reduced grid the detail stage uses, so a tile's
/// grids line up with the untiled frame's.
pub const TILE_ALIGN: u32 = 16;
/// One tile of a render: what to draw, and which part of it to keep.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Tile {
/// `[x, y, width, height]` in output pixels: the tile grown by the halo,
/// clamped to the frame. This is what is rendered.
pub grown: [u32; 4],
/// `[x, y, width, height]` in output pixels: the tile proper, which lies
/// inside `grown`. This is what is kept.
pub keep: [u32; 4],
}
impl Tile {
/// The rendered rectangle as a view on the frame, the rectangle
/// [`crate::Framing::set_view`] takes.
pub fn view(&self, frame: (u32, u32)) -> crate::framing::CropRect {
let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32);
crate::framing::CropRect {
x: self.grown[0] as f32 / fw,
y: self.grown[1] as f32 / fh,
width: self.grown[2] as f32 / fw,
height: self.grown[3] as f32 / fh,
}
}
/// Where the kept rectangle starts inside the rendered one.
pub fn keep_offset(&self) -> (u32, u32) {
(self.keep[0] - self.grown[0], self.keep[1] - self.grown[1])
}
}
/// Cut a `frame`-sized render into tiles no larger than `max_edge` once
/// grown by `halo` on every side.
///
/// Row-major, top to bottom, so a caller writing the file as it goes gets
/// its bands in order. A frame that fits whole is one tile with no halo.
/// `None` when the halo leaves no room for a tile at all — a spot heal
/// cloning from across a frame wider than the device can hold is the case,
/// and it has to be refused rather than drawn with a seam.
pub fn plan(frame: (u32, u32), max_edge: u32, halo: u32) -> Option<Vec<Tile>> {
let (fw, fh) = (frame.0.max(1), frame.1.max(1));
if fw <= max_edge && fh <= max_edge {
return Some(vec![Tile {
grown: [0, 0, fw, fh],
keep: [0, 0, fw, fh],
}]);
}
// The halo, rounded up so a grown origin lands on the grid; the tile
// proper a multiple of it for the same reason.
let halo = halo.div_ceil(TILE_ALIGN) * TILE_ALIGN;
let room = max_edge.checked_sub(2 * halo)?;
let step = room / TILE_ALIGN * TILE_ALIGN;
if step == 0 {
return None;
}
let mut out = Vec::new();
let mut y = 0;
while y < fh {
let kh = step.min(fh - y);
let mut x = 0;
while x < fw {
let kw = step.min(fw - x);
let gx = x.saturating_sub(halo);
let gy = y.saturating_sub(halo);
let gx1 = (x + kw + halo).min(fw);
let gy1 = (y + kh + halo).min(fh);
out.push(Tile {
grown: [gx, gy, gx1 - gx, gy1 - gy],
keep: [x, y, kw, kh],
});
x += kw;
}
y += kh;
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_frame_that_fits_is_one_tile_with_no_halo() {
let tiles = plan((6000, 4000), 8192, 200).unwrap();
assert_eq!(tiles.len(), 1);
assert_eq!(tiles[0].grown, [0, 0, 6000, 4000]);
assert_eq!(tiles[0].keep, tiles[0].grown);
}
#[test]
fn the_kept_rectangles_cover_the_frame_exactly_once() {
// The panorama that started this, against a 16384 device with a
// clarity-sized halo.
let frame = (22927, 8966);
let tiles = plan(frame, 16384, 230).unwrap();
let mut covered = vec![0u8; (frame.0 * frame.1) as usize];
for t in &tiles {
let [x, y, w, h] = t.keep;
for yy in y..y + h {
for xx in x..x + w {
covered[(yy * frame.0 + xx) as usize] += 1;
}
}
}
assert!(covered.iter().all(|&c| c == 1));
}
#[test]
fn every_tile_fits_the_device_and_holds_its_halo() {
let frame = (22927, 8966);
let (max, halo) = (8192, 300);
for t in plan(frame, max, halo).unwrap() {
let [gx, gy, gw, gh] = t.grown;
let [kx, ky, kw, kh] = t.keep;
assert!(gw <= max && gh <= max, "{t:?} does not fit");
assert_eq!(gx % TILE_ALIGN, 0, "{t:?} starts off the grid");
assert_eq!(gy % TILE_ALIGN, 0, "{t:?} starts off the grid");
// The halo is there on every side, or the frame ends first — in
// which case the untiled render stops at the same edge.
assert!(gx == 0 || kx - gx >= halo);
assert!(gy == 0 || ky - gy >= halo);
assert!(gx + gw == frame.0 || gx + gw - (kx + kw) >= halo);
assert!(gy + gh == frame.1 || gy + gh - (ky + kh) >= halo);
}
}
#[test]
fn a_halo_wider_than_the_device_is_refused() {
assert_eq!(plan((40000, 100), 16384, 9000), None);
}
}
+276
View File
@@ -0,0 +1,276 @@
//! TRACES: FR-DEV-3j | FR-DEV-2
//! The view transform — the one stage that maps scene-linear colour to a
//! display range (D19, ARCH §6.14).
//!
//! # What it is
//!
//! A log-logistic sigmoid, per channel:
//!
//! ```text
//! f(x) = w · r / (1 + r), r = (x / k)^n
//! ```
//!
//! `n` is the contrast — the slope in log-log terms, before the shoulder
//! bends it. `k` and `w` are solved from two conditions rather than set:
//! scene middle grey lands on display middle grey, and the scene white the
//! photographer chose lands on display white. So the curve has a toe, a
//! midtone slope and a shoulder that approaches `w` — a hair above 1.0 —
//! without ever reaching it. Everything the shoulder has not reached by the
//! white point is clipped by the output transform, which is the last moment
//! and the only place a clip belongs.
//!
//! # Why per channel, and why the middle channel is put back
//!
//! Per channel is what makes a bright saturated colour desaturate as it
//! approaches white — a blown sky rolls toward white rather than toward a
//! saturated corner of the gamut, which is what film and every camera JPEG
//! do. It also bends hue: the three channels sit at different places on the
//! curve, so their ratios change, and an orange flame drifts toward yellow.
//! So after the curve the middle channel is moved back to where it sat
//! *between the other two* before it — the same fraction of the way from the
//! smallest to the largest. The smallest and largest keep what the curve gave
//! them, which keeps the desaturation; the hue, which is decided by that
//! fraction, survives. It is the "preserve hue" step of darktable's sigmoid,
//! at full strength.
//!
//! # Why these defaults
//!
//! [`SCENE_GREY`] is where the retired default base curve put middle grey
//! (FR-DEV-3e): linear sensor data from a correctly exposed frame has it
//! near 13% of saturation, and a camera JPEG shows it at 18%. The contrast
//! and white defaults were chosen against that same retired curve: at 1.4 and
//! 4 stops the midtones stay within a quarter of a stop of it between scene
//! 0.03 and 1.0, while a highlight a stop past sensor saturation still rolls
//! into white rather than stopping dead at it. The upper midtones come out a
//! little darker than the curve had them, which is the price of that
//! headroom and what the white slider is for.
/// Scene-linear middle grey: where the retired default curve placed it.
pub const SCENE_GREY: f32 = 0.13;
/// Display-linear middle grey — what a camera JPEG shows a grey card as.
pub const DISPLAY_GREY: f32 = 0.18;
/// The default contrast, the sigmoid's log-log slope parameter `n`.
pub const DEFAULT_CONTRAST: f32 = 1.4;
/// The default white point, in stops above [`SCENE_GREY`].
pub const DEFAULT_WHITE: f32 = 4.0;
/// The contrast range a photographer is offered.
pub const CONTRAST_RANGE: (f32, f32) = (1.0, 3.0);
/// The white point range, in stops above middle grey.
///
/// The floor is not taste. The two conditions `k` and `w` are solved from
/// have a solution only while `2^(white · n)` exceeds `1 / DISPLAY_GREY`,
/// and at the lowest contrast that needs `white` above about 2.47 stops.
pub const WHITE_RANGE: (f32, f32) = (2.5, 10.0);
/// The curve's three numbers, solved from the photographer's two.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Sigmoid {
/// Contrast: the exponent.
pub n: f32,
/// `1 / k`, so the shader multiplies rather than divides.
pub inv_k: f32,
/// The asymptote the shoulder approaches, a little above 1.0.
pub w: f32,
}
impl Sigmoid {
/// Solve the curve for a contrast and a white point in stops.
///
/// Out-of-range inputs are clamped to [`CONTRAST_RANGE`] and
/// [`WHITE_RANGE`] rather than trusted: they arrive from a sidecar, which
/// may have been written by a build with other limits, and outside them
/// the solution below divides by something that is no longer positive.
///
/// With `r_g` the value of `r` at scene grey and `q = 2^(white · n)`, the
/// two conditions `f(grey) = display grey` and `f(grey · 2^white) = 1`
/// are `w·r_g/(1+r_g) = g` and `w·q·r_g/(1+q·r_g) = 1`. Dividing one by
/// the other eliminates `w` and leaves `r_g = (g·q − 1) / (q·(1 − g))`.
pub fn new(contrast: f32, white: f32) -> Self {
let n = contrast.clamp(CONTRAST_RANGE.0, CONTRAST_RANGE.1) as f64;
let white = white.clamp(WHITE_RANGE.0, WHITE_RANGE.1) as f64;
let g = f64::from(DISPLAY_GREY);
let q = (white * n).exp2();
let r_grey = (g * q - 1.0) / (q * (1.0 - g));
let w = g * (1.0 + r_grey) / r_grey;
// r = (x / k)^n, and r at scene grey is r_grey, so
// k = grey / r_grey^(1/n).
let k = f64::from(SCENE_GREY) / r_grey.powf(1.0 / n);
Self {
n: n as f32,
inv_k: (1.0 / k) as f32,
w: w as f32,
}
}
/// The default curve.
pub fn default_curve() -> Self {
Self::new(DEFAULT_CONTRAST, DEFAULT_WHITE)
}
/// One channel through the curve. The CPU reference the shader is
/// tested against.
pub fn channel(&self, x: f32) -> f32 {
let r = (x.max(0.0) * self.inv_k).powf(self.n);
self.w * r / (1.0 + r)
}
/// A colour through the curve, with the middle channel put back between
/// the other two. See the module documentation.
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
let x = c.map(|v| v.max(0.0));
let y = x.map(|v| self.channel(v));
let lo = x[0].min(x[1]).min(x[2]);
let hi = x[0].max(x[1]).max(x[2]);
if hi - lo <= 1e-9 {
return y;
}
let (y_lo, y_hi) = (self.channel(lo), self.channel(hi));
x.map(|v| y_lo + (y_hi - y_lo) * (v - lo) / (hi - lo))
}
}
/// The WGSL twin of [`Sigmoid::apply`], as a helper function.
pub const VIEW_SIGMOID_WGSL: &str = "\
// The view transform (FR-DEV-3j): a log-logistic sigmoid per channel, then the
// middle channel put back between the other two so that the hue survives the
// shoulder. See `dr_pipeline::view` for the derivation and the defaults.
fn view_sigmoid(c: vec3<f32>, n: f32, inv_k: f32, w: f32) -> vec3<f32> {
// Negative components are colours outside the working primaries. They are
// floored here, at the last stage, which is the one place a gamut clip
// belongs.
let x = max(c, vec3<f32>(0.0));
let lo = min(x.r, min(x.g, x.b));
let hi = max(x.r, max(x.g, x.b));
let r_lo = pow(lo * inv_k, n);
let r_hi = pow(hi * inv_k, n);
let y_lo = w * r_lo / (1.0 + r_lo);
let y_hi = w * r_hi / (1.0 + r_hi);
// Each channel's place between the smallest and the largest. A neutral
// has no spread, and every channel then takes the one value there is.
let spread = hi - lo;
let t = select((x - vec3<f32>(lo)) / max(spread, 1e-9), vec3<f32>(0.0), spread <= 1e-9);
return vec3<f32>(y_lo) + (y_hi - y_lo) * t;
}
";
#[cfg(test)]
mod tests {
use super::*;
/// The retired default base curve, for the acceptance comparison: five
/// points through the unit square, sampled here by straight lines between
/// them in log-log terms — close enough to the monotone spline that drew
/// it for a tolerance measured in quarters of a stop. Below its second
/// point it was a straight line from the origin.
fn retired_default(x: f32) -> f32 {
const P: [(f32, f32); 4] = [(0.04, 0.043), (0.13, 0.175), (0.45, 0.690), (1.0, 1.0)];
if x < 0.04 {
return x * (0.043 / 0.04);
}
let x = x.min(1.0);
let i = P.windows(2).position(|w| x <= w[1].0).unwrap_or(2);
let ((x0, y0), (x1, y1)) = (P[i], P[i + 1]);
let t = (x.ln() - x0.ln()) / (x1.ln() - x0.ln());
(y0.ln() + t * (y1.ln() - y0.ln())).exp()
}
#[test]
fn middle_grey_lands_on_display_grey() {
// TRACES: FR-DEV-3j
for (contrast, white) in [(1.0, 3.0), (1.4, 4.0), (2.5, 8.0), (3.0, 10.0)] {
let s = Sigmoid::new(contrast, white);
let got = s.channel(SCENE_GREY);
assert!(
(got - DISPLAY_GREY).abs() < 0.01,
"contrast {contrast}, white {white}: grey went to {got}"
);
}
}
#[test]
fn the_white_point_reaches_display_white() {
// TRACES: FR-DEV-3j
// The whole meaning of the slider: the scene value it names is where
// the picture reaches white, and not before.
for (contrast, white) in [(1.0, 3.0), (1.4, 4.0), (2.5, 8.0)] {
let s = Sigmoid::new(contrast, white);
let at = SCENE_GREY * white.exp2();
assert!((s.channel(at) - 1.0).abs() < 1e-4, "{}", s.channel(at));
assert!(s.channel(at * 0.9) < 1.0);
assert!(s.w > 1.0, "the shoulder must approach a value above white");
}
}
#[test]
fn the_curve_is_monotone_and_keeps_going_past_one() {
// TRACES: FR-DEV-3j | FR-DEV-2
// What the base curve got wrong: it was flat past 1.0, so every
// recovered highlight left it as the same number.
let s = Sigmoid::default_curve();
let mut last = -1.0;
for i in 0..=2000 {
let x = i as f32 * 0.004;
let y = s.channel(x);
assert!(y > last || (x == 0.0 && y == 0.0), "not increasing at {x}");
last = y;
}
assert!(s.channel(2.0) > s.channel(1.0));
}
#[test]
fn the_default_stays_close_to_the_retired_curve() {
// TRACES: FR-DEV-3j | FR-DEV-3e
// D19's promise to every existing photograph: the midtones do not
// move by more than a third of a stop.
let s = Sigmoid::default_curve();
let mut x = 0.03_f32;
while x <= 1.0 {
let ev = (s.channel(x) / retired_default(x)).log2();
assert!(ev.abs() < 0.3, "at scene {x} the default moved {ev:+.2} EV");
x *= 1.1;
}
}
#[test]
fn a_neutral_stays_neutral() {
// TRACES: FR-DEV-3j
let s = Sigmoid::default_curve();
for v in [0.0, 0.01, 0.13, 1.0, 7.0] {
let [r, g, b] = s.apply([v, v, v]);
assert_eq!(r, g);
assert_eq!(g, b);
}
}
#[test]
fn the_hue_survives_the_shoulder() {
// TRACES: FR-DEV-3j
// The middle channel's place between the other two is what decides
// the hue. Without the correction an orange at the shoulder drifts
// toward yellow as the red channel saturates first.
let s = Sigmoid::default_curve();
let orange = [2.0, 0.8, 0.1];
let out = s.apply(orange);
let before = (orange[1] - orange[2]) / (orange[0] - orange[2]);
let after = (out[1] - out[2]) / (out[0] - out[2]);
assert!((before - after).abs() < 1e-5, "{before} became {after}");
// And the extremes keep what the curve gave them — the desaturation
// toward white is the point of working per channel.
assert!((out[0] - s.channel(2.0)).abs() < 1e-6);
assert!((out[2] - s.channel(0.1)).abs() < 1e-6);
}
#[test]
fn out_of_range_settings_are_clamped_not_trusted() {
// A sidecar from another build may carry anything, and outside the
// range the solution divides by a value that is no longer positive.
let s = Sigmoid::new(0.0, 0.0);
assert!(s.n.is_finite() && s.inv_k.is_finite() && s.w.is_finite());
assert_eq!(s, Sigmoid::new(CONTRAST_RANGE.0, WHITE_RANGE.0));
}
}

Some files were not shown because too many files have changed in this diff Show More