Commit Graph
531 Commits
Author SHA1 Message Date
dtourolle 54772f94d6 Correct what D20 claimed the profile tables would do for colour
Measured after building them, on four of the library's 6D DNGs: Adobe
Standard's tables lower mean saturation by 3-9 % at defaults, and the
look at 200 % lowers it further. The 6D's look table scales saturation
by 0.925 in its darkest value rows; it was tuned to sit under Camera
Raw's default RGB tone curve, which DarkRoom does not apply, and the
dark-tone desaturation is what is left without it. On _MG_9080 the
Lightroom preview measures 0.49, the matrix alone 0.38, the profile
0.35.

So the spec's "that gap is most of why the same file looks flatter"
was wrong: the gap is tone. The tables stay — they put each hue where
Adobe put it — and the spec, D20 and the Vivid file now say so.
"Stronger camera look" is removed: a stronger Adobe Standard look is a
less saturated picture, the opposite of its name. The Vivid presets,
measured at 0.40-0.46 on the same frame, are what answers "more
colourful" today.
2026-10-02 22:38:07 -04:00
dtourolle 65c1f1a468 Let the develop example render the profile off, the look doubled, or a preset
Diagnostic only. "matrix" switches the camera profile off and
"look200" doubles its look, so a DNG's tables can be judged against
the matrix render; "preset:<name>" applies a shipped preset as the
menu does. The example now renders through render_detailed, the path
every frontend takes, because a preset with clarity in it composes a
detail stage that plain render refuses.
2026-10-02 22:38:07 -04:00
dtourolle cb7ad0bbe7 Ship a Vivid section of presets
Five looks for "more colourful than the default": Vivid, Vivid strong,
Vivid landscape, Vivid warm and Vivid portrait. They lean on vibrance,
which lifts muted colours most and holds skin back, and use saturation
sparingly on top; landscape and portrait work the colour mixer's bands
so foliage and sky get richer while skin does not. A sixth, Stronger
camera look, pushes the camera profile's look table to 175 %, about the
step from Adobe Standard to Adobe Vivid, and only moves vibrance where
a photograph has no profile.

All change only what they name, so they keep a corrected exposure or
white balance, and the shipped-preset tests bound every key and value.
2026-10-02 22:38:07 -04:00
dtourolle c02b401a9a Apply a camera profile's HueSatMap and LookTable after exposure
The second half of D20: a camera_profile scene operation at order 25
that converts working colour into linear ProPhoto, runs the DNG SDK's
HSV lookup through the HueSatMap and then the LookTable, and converts
back. Hue and saturation do not change under the uniform gains before
it, so a 2.5-D HueSatMap gives the same answer as straight after the
matrix, and the look sees the photographer's exposure as it does in
the SDK. Two departures for scene-referred values: value is not
clamped on the way out, and a colour outside ProPhoto passes through.

The operation holds only the switch (on by default) and a look
strength of 0-200 %. It is composed while the switch is on — a new
Operation::composes() separates "does something" from "moved from the
defaults", so an untouched raw renders through its profile and still
writes nothing. The tables come from the source: dr-gpu uploads the
ones DemosaicedImage carries into a storage buffer at @binding(8),
whose two-entry header tells the fragment whether there is anything to
apply, and binds a header of zeros for every other source.

apply_reference is the lookup on the CPU. The GPU test holds the
shader to it over 256 colours, through synthetic tables strong enough
that a wrong index shows, and through the library's real Adobe
Standard tables when the 6D DNG is present.
2026-10-02 22:38:06 -04:00
dtourolle f6a3f3f4e2 Read DCP camera profiles: embedded in a DNG, or a .dcp beside the app
The first half of D20. dr-decode now finds a camera profile's HueSatMap
and LookTable in the order camera-profiles.md §4 gives: the profile a
DNG embeds, then a .dcp in the profiles directory whose
UniqueCameraModel names the body, then none. A .dcp brings its own
matrices, since its tables were measured against its forward matrix.

The HueSatMap is blended for the frame's colour temperature with the
same mired weight the matrices use, once per decode, and the result
rides on RawImage as profile_tables beside color_matrix, so every path
that renders a decoded file gets the same profile without a setter to
forget. Nothing applies the tables yet.

A profile whose embed policy allows copying can be written back out as
a .dcp (rawler's TIFF writer with the RC magic patched in), which is how
the library's 6D CR2s will get the Adobe Standard their DNGs carry. The
table type lives in dr-types because decode, pipeline and GPU all need
its layout. Tests read the library's 6D DNG when it is present.
2026-10-02 22:38:06 -04:00
dtourolle 23a2f13b46 Apply the collision policy to exports bound for the server
A queued export could not see the server, so its name check always
answered "free" and the upload PUT over whatever was there: Increment
and Skip behaved as Overwrite on Nextcloud, and two exports of the same
name queued before either uploaded landed on one file.

The batch now names around what the album records of earlier exports
and what the outbox already holds for that folder. The outbox record
carries the policy, and the drain lists each destination folder once
and applies it against what the server holds: Increment steps past a
taken name and re-points the album's row, Skip drops the entry. A
record without a policy (older builds, a merge's composite) is sent as
named, as before. The album is recorded before the drain starts so a
rename has a row to move.
2026-10-02 19:40:27 -04:00
dtourolle 6f33517b35 Cut panorama overlaps along seams instead of averaging them
The merge weighted every overlap pixel by its distance from each frame's
edge, a 200 px linear cross-fade. Anything the frames disagreed on —
parallax in the near foreground, grass in the wind, a walker — came out
twice at half strength: a soft double edge at 1:1.

dr_pano::seam picks, per output texel at proxy resolution, which frame a
pixel comes from. Where a new frame overlaps the composite the cost is the
gain-corrected difference plus local detail plus nearness to either
frame's edge, taken as the worst over a small window, and the cut is a
dynamic-programming path across the overlap. merge.wgsl weights each frame
by its tent-filtered share of that map, a 64 px blend that follows the
seam, with the edge feather kept as the fallback. The page's preview uses
the same map, and examples/merge.rs takes --feather-only for comparison.
2026-09-30 21:57:44 -04:00
dtourolle 1d7115437b Date a photograph from its name when its header has none
WhatsApp strips every EXIF tag and names the file "WhatsApp Image
2023-06-15 at 07.00.42.jpeg"; Windows Phone, Android cameras and
darktable's import put the date in the name too. Those images sorted
after everything else and were absent from the timeline.

name_dates reads a date (and a time, when one follows) from the file
name, then from the innermost folder that states one. A sequence number
after a date is not read as a time, and a bare year folder is not a date.
EXIF always wins: only examined rows still undated are filled.

The sweep and the metadata repair fill as they mark an image examined,
and the open backfill fills catalogs examined by earlier builds. On the
reference library that takes 274 undated images to 10; the no-op case is
a seek on images_captured, 0.6 ms an open.
2026-09-30 21:45:54 -04:00
dtourolle 1bc04870c3 Let a crop be trimmed from one edge
Four bar handles at the midpoints of the sides, each moving only its
own side along the axis across it. The overlay reports an edge as 0.5
on the axis it does not move, so the anchor Rust takes is the middle
of the far side.

Under a ratio lock the dragged axis leads. with_aspect grew the short
axis onto the ratio, which for an edge pulled inward made the untouched
axis the leader and pushed the edge straight back out.
2026-09-29 21:31:27 -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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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